Created
October 8, 2024 09:17
-
-
Save n1c01a5/fe93e0f224173b19566c28854260b417 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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