import { ChangeEvent, KeyboardEvent, useCallback, useMemo, useRef, useState, ReactNode, ComponentProps, CSSProperties, RefObject, } from "react"; import type { OptionLike, Option } from "../select-legacy"; import { AutocompleteOption, AutocompleteOptionProps } from "./AutocompleteOption"; import { FloatingFocusManager, FloatingList, FloatingPortal, Placement, autoUpdate, flip, offset, size, useDismiss, useFloating, useInteractions, useListNavigation, useRole, } from "@floating-ui/react"; import { AutocompleteContext } from "./useAutocompleteContext"; import clsx from "clsx"; import { getOptionLabel, getOptionValue } from "./util"; import { Button } from "../button"; import { Dialog } from "../dialog"; import { Loader } from "../loader"; import { inputConfig, sizeVariant } from "../fields/text/Input"; import { ThemeColor } from "../../types"; export interface AutocompleteProps extends ComponentProps<"input"> { icon?: boolean | ReactNode; color?: ThemeColor; /** * render suffix only if selection and searchValue is empty */ noSearchSuffix?: ReactNode; suffix?: ReactNode; placement?: Placement; /** * search string used as value for */ searchValue: string; /** * change handler when typing in the input * or when clicking on an option (the label value is passed) */ onChangeSearchValue: (value: string, selectionLabel: boolean) => void; selection?: O | null; /** * select handler for a selection from the autocomplete's dropdown * the option object is passed when clicking on an option * null value is passed when typing a new character in the input search. */ onChangeSelection: (option: O | null) => void; options: O[]; autocompleteOptionComponent?: (props: AutocompleteOptionProps) => ReactNode; loading?: boolean; clearSearchButton?: boolean; selectOnFocus?: boolean; ref?: RefObject; } export function Autocomplete({ className, autoFocus = false, icon = true, color = "yellow", noSearchSuffix, suffix, placement = "bottom", placeholder = "Search...", selection = null, onChangeSelection, autocompleteOptionComponent, loading = false, clearSearchButton = false, searchValue, onChangeSearchValue, options, selectOnFocus = true, readOnly = false, ref, ...rest }: AutocompleteProps) { const [isOpen, setIsOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(null); const OptionComponent = autocompleteOptionComponent ?? AutocompleteOption; const listRef = useRef>([]); const { refs, floatingStyles, context } = useFloating({ placement, open: isOpen, onOpenChange: setIsOpen, whileElementsMounted: autoUpdate, middleware: [ offset(5), flip({ padding: 10 }), size({ apply({ rects, elements, availableHeight }) { Object.assign(elements.floating.style, { width: `${Math.max(130, rects.reference.width)}px`, }); const firstChild = elements.floating.firstElementChild as HTMLElement; if (firstChild) { firstChild.style.maxHeight = `${Math.min(availableHeight, 288)}px`; } }, padding: 10, }), ], }); const role = useRole(context, { role: "listbox" }); const dismiss = useDismiss(context); const listNav = useListNavigation(context, { listRef, activeIndex, onNavigate: setActiveIndex, /* we want the real focus to stay on the input, so focus associated to active items is simulated */ virtual: true, loop: true, }); const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([ role, dismiss, listNav, ]); function handleChangeSearchValue(e: ChangeEvent) { const value = e.target.value; onChangeSearchValue(value, false); if (value !== searchValue && selection !== null) { onChangeSelection(null); setActiveIndex(null); return; } if (value) { setIsOpen(true); /** * bug whith LazyAutocomplete activeIndex is set to 0 but the options array has immediately 0 elements * so focus can't be set to the first Dom element. */ setActiveIndex(listRef.current[0] ? 0 : null); } else { setIsOpen(false); setActiveIndex(null); } } const handleSelect = useCallback( (index: number | null) => { if (index === null) { onChangeSelection(null); } else if (options[index]) { const newSelection = options[index]; onChangeSelection(newSelection); onChangeSearchValue(getOptionLabel(newSelection), true); } setActiveIndex(null); setIsOpen(false); }, [options, onChangeSelection, onChangeSearchValue], ); const autocompleteContext = useMemo( () => ({ activeIndex, selection, getItemProps, handleSelect, }), [activeIndex, selection, getItemProps, handleSelect], ); const showClearSearchButton = clearSearchButton && (searchValue.trim() !== "" || selection !== null); return (
{icon !== false && (
{icon === true ? : icon}
)} !isOpen); }, onKeyDown(e: KeyboardEvent) { if (e.key === "Enter" && activeIndex != null) { handleSelect(activeIndex); } }, })} {...rest} />
{suffix} {showClearSearchButton && ( )} {icon === false && loading && } {!selection && searchValue === "" && noSearchSuffix}
{isOpen && (
{options.map((option) => { return ; })}
)}
); }