import { forwardRef, useCallback, useRef, useState, type ElementRef, type KeyboardEvent, type ReactElement, type Ref, } from 'react'; import { useMergeRefs } from '@floating-ui/react'; import { Portal } from '@radix-ui/react-portal'; import { useControllableState } from '@radix-ui/react-use-controllable-state'; import { tv } from 'tailwind-variants'; import { type Option, type VisibleState } from '../../types'; import { cn } from '../../utils/cn'; import { CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '../command'; import { Command as CommandPrimitive } from '../command/cmdk'; import { commandScore } from '../command/cmdk/command-score'; import { CheckIcon, CloseIcon, SearchIcon } from '../icons'; import { inputVariants, type InputProps } from '../input/input'; import { PopperAnchor, PopperContent, PopperRoot, type PopperContentProps } from '../popper'; import { Skeleton } from '../skeleton'; import { Tag } from '../tag'; import { Show, VStack } from '../utility'; type TValue = T extends true ? string[] : string; const autocompleteVariants = tv({ base: 'inline-flex flex-col', variants: { fullWidth: { true: 'w-full', false: 'w-auto', }, }, defaultVariants: { fullWidth: true, }, }); export interface AutocompleteProps extends Omit, VisibleState { options: Option[]; value?: TValue; defaultValue?: TValue; onValueChange?: (value: TValue) => void; onInputChange?: (value: string) => void; loading?: boolean; disabled?: boolean; allowsCustomValue?: boolean; clearable?: boolean; placeholder?: string; name?: string; multiple?: T; popperProps?: PopperContentProps; listOptionClassName?: string; } const AutocompleteComponent = ( props: AutocompleteProps, autoCompleteRef: Ref> ) => { const { options = [], onValueChange, loading = false, clearable, suffix, onInputChange, variant, size = 'md', className, prefix, open: openProp, defaultOpen, value: valueProp, defaultValue = (props?.multiple ? [] : undefined) as any, onOpenChange, multiple, allowsCustomValue, fullWidth, radius = 'xs', popperProps, listOptionClassName, ...etc } = props; const inputRef = useRef(null); const ref = useMergeRefs([autoCompleteRef, inputRef]); const [isOpen = false, setOpen] = useControllableState({ prop: openProp, defaultProp: defaultOpen, onChange: onOpenChange, }); const [selected, setSelected] = useControllableState({ prop: valueProp, defaultProp: defaultValue, onChange: onValueChange, }); const handleNewValue = useCallback( (val: string) => { if (!multiple) { setSelected(val as TValue); return; } let newValue = (selected ?? []) as string[]; // Hack for switch from single to multiple if (typeof selected === 'string') { newValue = [selected]; } const itemIndex = newValue?.findIndex((x) => x === val); if (itemIndex !== -1) { newValue.splice(itemIndex, 1); setInputValue(''); } else { newValue.push(val); setSelected(newValue as TValue); setInputValue(''); } }, [multiple, selected, setSelected] ); const handleRemoveValue = useCallback( (val: string) => { if (typeof selected === 'string') return; const newSelected = selected?.filter((x) => x !== val) as TValue; setSelected(newSelected); return newSelected; }, [selected, setSelected] ); const [inputValue, setInputValue] = useState(); const handleKeyUp = useCallback( (event: KeyboardEvent) => { const input = inputRef.current; if (!input) { return; } // Keep the options displayed when the user is typing if (!isOpen) { setOpen(true); } // This is not a default behavior of the field if (allowsCustomValue) { if (multiple) { if (event.key === 'Enter' && input.value !== '') { handleNewValue(input.value); } } else { handleNewValue(input.value); } } else { if (event.key === 'Enter' && input.value !== '') { const optionToSelect = options.find((option) => option.label === input.value); if (optionToSelect) { handleNewValue(optionToSelect.value); } } } if (event.key === 'Escape') { input.blur(); } }, [allowsCustomValue, handleNewValue, isOpen, multiple, options, setOpen] ); const handleBlur = useCallback( (e: React.FocusEvent) => { setOpen(false); if (e.relatedTarget) return; if (!multiple) { setInputValue((selected ?? inputValue) as string); } }, [inputValue, multiple, selected, setOpen] ); const handleSelectOption = useCallback( (selectedValue: Option) => { if (props?.disabled) return; setInputValue(selectedValue.value); handleNewValue(selectedValue.value); // This is a hack to prevent the input from being focused after the user selects an option // We can call this hack: "The next tick" setTimeout(() => { inputRef?.current?.blur(); }, 0); }, [props?.disabled, handleNewValue] ); const handleChange = (val: string) => { onInputChange?.(val); setInputValue(val); }; const handleClear = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); onInputChange?.(''); setInputValue(''); setSelected((multiple ? [] : '') as TValue); }; /** * * This is a hack to prevent the input from being focused after the user selects an option */ const handleSearch = (val: string, search: string) => { const input = inputRef.current; if (input?.value !== selected) { return commandScore(val, search); } return 1; }; return (
{prefix ?? } {multiple && Array.isArray(selected) && selected?.length !== 0 && ((selected ?? []) as string[])?.map((value) => ( handleRemoveValue(value)} key={value}> {value} ))} setOpen(true)} prefix={<>} className={cn('peer h-full flex-1')} fullWidth wrapperClassName={cn('border-none p-0 flex-1 min-w-28')} {...etc} />
{clearable ? ( ) : null} {suffix ? (
{suffix}
) : null}
{isOpen && ( {loading ? ( ) : ( No Options )} {(loading ? [] : options).map((option) => { const isSelected = multiple ? selected?.includes(option.value) : selected === option.value; return ( { event.preventDefault(); event.stopPropagation(); }} onSelect={() => handleSelectOption(option)} className={cn('flex w-full items-center justify-between gap-2', { 'bg-background-light': isSelected, 'cursor-not-allowed': props.disabled, })} > {option.label} {isSelected ? : null} ); })} )}
); }; export const Autocomplete = forwardRef(AutocompleteComponent) as ( p: AutocompleteProps & { ref?: Ref> } ) => ReactElement;