Skip to content

Instantly share code, notes, and snippets.

@n1c01a5
Created October 8, 2024 09:17
Show Gist options
  • Select an option

  • Save n1c01a5/fe93e0f224173b19566c28854260b417 to your computer and use it in GitHub Desktop.

Select an option

Save n1c01a5/fe93e0f224173b19566c28854260b417 to your computer and use it in GitHub Desktop.
import {
CommandGroup,
CommandItem,
CommandList,
CommandInput,
} from "./command"
import { Command as CommandPrimitive } from "cmdk"
import { useState, useRef, useCallback, type KeyboardEvent, useEffect } from "react"
import { Skeleton } from "./skeleton"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
export type Option = Record<"value" | "label", string> & Record<string, string>
type AutoCompleteProps = {
options: Option[]
emptyMessage: string
value?: Option
onValueChange?: (value: Option) => void
isLoading?: boolean
disabled?: boolean
placeholder?: string
minChars?: number
onSearch: (query: string) => Promise<Option[]>
}
export const AutoComplete = ({
options: initialOptions,
placeholder,
emptyMessage,
value,
onValueChange,
disabled,
isLoading: initialIsLoading = false,
minChars = 2,
onSearch,
}: AutoCompleteProps) => {
const inputRef = useRef<HTMLInputElement>(null)
const [isOpen, setOpen] = useState(false)
const [selected, setSelected] = useState<Option>(value as Option)
const [inputValue, setInputValue] = useState<string>(value?.label || "")
const [options, setOptions] = useState<Option[]>(initialOptions)
const [isLoading, setIsLoading] = useState(initialIsLoading)
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current
if (!input) {
return
}
if (!isOpen) {
setOpen(true)
}
if (event.key === "Enter" && input.value !== "") {
const optionToSelect = options.find(
(option) => option.label === input.value,
)
if (optionToSelect) {
setSelected(optionToSelect)
onValueChange?.(optionToSelect)
}
}
if (event.key === "Escape") {
input.blur()
}
},
[isOpen, options, onValueChange],
)
const handleBlur = useCallback(() => {
setOpen(false)
setInputValue(selected?.label)
}, [selected])
const handleSelectOption = useCallback(
(selectedOption: Option) => {
setInputValue(selectedOption.label)
setSelected(selectedOption)
onValueChange?.(selectedOption)
setTimeout(() => {
inputRef?.current?.blur()
}, 0)
},
[onValueChange],
)
const handleInputChange = useCallback(async (value: string) => {
setInputValue(value)
if (value.length >= minChars) {
setIsLoading(true)
try {
const searchResults = await onSearch(value)
setOptions(searchResults)
} catch (error) {
console.error("Error searching:", error)
} finally {
setIsLoading(false)
}
} else {
setOptions(initialOptions)
}
}, [minChars, onSearch, initialOptions])
useEffect(() => {
setOptions(initialOptions)
}, [initialOptions])
return (
<CommandPrimitive onKeyDown={handleKeyDown}>
<div>
<CommandInput
ref={inputRef}
value={inputValue}
onValueChange={handleInputChange}
onBlur={handleBlur}
onFocus={() => setOpen(true)}
placeholder={placeholder}
disabled={disabled}
className="text-base"
/>
</div>
<div className="relative mt-1">
<div
className={cn(
"animate-in fade-in-0 zoom-in-95 absolute top-0 z-10 w-full rounded-xl bg-white outline-none",
isOpen ? "block" : "hidden",
)}
>
<CommandList className="rounded-lg ring-1 ring-slate-200">
{isLoading ? (
<CommandPrimitive.Loading>
<div className="p-1">
<Skeleton className="h-8 w-full" />
</div>
</CommandPrimitive.Loading>
) : null}
{options.length > 0 && !isLoading ? (
<CommandGroup>
{options.map((option) => {
const isSelected = selected?.value === option.value
return (
<CommandItem
key={option.value}
value={option.label}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onSelect={() => handleSelectOption(option)}
className={cn(
"flex w-full items-center gap-2",
!isSelected ? "pl-8" : null,
)}
>
{isSelected ? <Check className="w-4" /> : null}
{option.label}
</CommandItem>
)
})}
</CommandGroup>
) : null}
{!isLoading && options.length === 0 ? (
<CommandPrimitive.Empty className="select-none rounded-sm px-2 py-3 text-center text-sm">
{emptyMessage}
</CommandPrimitive.Empty>
) : null}
</CommandList>
</div>
</div>
</CommandPrimitive>
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment