import { useCombobox } from 'downshift' import { forwardRef, useState, useMemo, Fragment } from 'react' import { useTranslation } from 'react-i18next' import classnames from 'classnames' import MiniSearch from 'minisearch' import OLButton from '@/shared/components/ol/ol-button' import OLFormControl from '@/shared/components/ol/ol-form-control' import OLFormLabel from '@/shared/components/ol/ol-form-label' import { DropdownHeader, DropdownItem, } from '@/shared/components/dropdown/dropdown-menu' import MaterialIcon from '@/shared/components/material-icon' const FUZZY_SEARCH_THRESHOLD = 0.5 export type OLAutocompleteItem = { value: string label: string group?: string } export type OLAutocompleteProps = { items: OLAutocompleteItem[] onChange: (value: string) => void placeholder?: string label: string showLabel?: boolean allowCreate?: boolean | ((value: string) => boolean) disabled?: boolean createOptionPrefix?: string useFuzzySearch?: boolean inputRef?: React.ForwardedRef expandUp?: boolean onClose?: () => void isOpen?: boolean } type OLAutocompleteDisplayItem = | { type: 'item' value: string label: string } | { type: 'create' inputValue: string } function OLAutocompleteInternal({ items, onChange, placeholder, label, showLabel = false, allowCreate = true, disabled = false, createOptionPrefix = '+ Create', useFuzzySearch = false, inputRef, expandUp = false, onClose, isOpen: controlledIsOpen, }: OLAutocompleteProps) { const { t } = useTranslation() const searchIndex = useMemo(() => { if (!useFuzzySearch) return null const searchIndex = new MiniSearch({ fields: ['label'], storeFields: ['value', 'label', 'group'], idField: 'value', }) searchIndex.addAll(items) return searchIndex }, [items, useFuzzySearch]) const [internalInputValue, setInternalInputValue] = useState('') const inputItems = useMemo(() => { if (!internalInputValue) { return items } if (useFuzzySearch && searchIndex) { const results = searchIndex.search(internalInputValue, { fuzzy: FUZZY_SEARCH_THRESHOLD, prefix: true, }) return results.map(result => ({ value: result.value, label: result.label, group: result.group, })) } return items.filter(item => item.label.toLowerCase().includes(internalInputValue.toLowerCase()) ) }, [items, internalInputValue, searchIndex, useFuzzySearch]) const exactMatch = inputItems.some( item => item.label.toLowerCase() === internalInputValue.toLowerCase() ) const allowCreateForInput = typeof allowCreate === 'function' ? allowCreate(internalInputValue) : allowCreate const showCreateOption = allowCreateForInput && internalInputValue && !exactMatch const createDisplayItem: OLAutocompleteDisplayItem[] = showCreateOption ? [{ type: 'create' as const, inputValue: internalInputValue }] : [] const displayItems: OLAutocompleteDisplayItem[] = [ ...(expandUp ? [] : createDisplayItem), ...inputItems.map(item => ({ type: 'item' as const, value: item.value, label: item.label, })), ...(expandUp ? createDisplayItem : []), ] const getDisplayIndex = (inputItemIndex: number) => !expandUp && showCreateOption ? inputItemIndex + 1 : inputItemIndex const hasGroupedItems = inputItems.some(item => Boolean(item.group)) const { isOpen, getLabelProps, getMenuProps, getInputProps, getItemProps, highlightedIndex, selectItem, } = useCombobox({ inputValue: internalInputValue, items: displayItems, defaultHighlightedIndex: 0, ...(controlledIsOpen !== undefined && { isOpen: controlledIsOpen }), itemToString: item => { if (!item) return '' return item.type === 'create' ? item.inputValue : item.label }, stateReducer: (_state, { type, changes }) => { if (type === useCombobox.stateChangeTypes.InputChange) { const newInputValue = changes.inputValue || '' const newAllowCreate = typeof allowCreate === 'function' ? allowCreate(newInputValue) : allowCreate const hasExactMatch = items.some( item => item.label.toLowerCase() === newInputValue.toLowerCase() ) const hasMatchingItems = items.some(item => item.label.toLowerCase().includes(newInputValue.toLowerCase()) ) const newShowCreate = newAllowCreate && newInputValue && !hasExactMatch return { ...changes, highlightedIndex: !expandUp && newShowCreate && hasMatchingItems ? 1 : 0, } } return changes }, onSelectedItemChange: ({ selectedItem }) => { if (selectedItem) { if (selectedItem.type === 'create') { onChange(selectedItem.inputValue) setInternalInputValue(selectedItem.inputValue) } else { onChange(selectedItem.value) setInternalInputValue(selectedItem.label) } } }, onInputValueChange: ({ inputValue = '' }) => { setInternalInputValue(inputValue) }, onIsOpenChange: ({ isOpen }) => { if (!isOpen) { onClose?.() } }, }) const shouldShowDropdown = isOpen && displayItems.length > 0 const renderCreateOption = (index: number) => ( <> {hasGroupedItems && expandUp && (
  • )}
  • {createOptionPrefix} '{internalInputValue}'
  • {hasGroupedItems && !expandUp && (
  • )} ) const handleClear = () => { selectItem(null) setInternalInputValue('') onChange('') } const renderSearchBar = () => (
    {label}
    {internalInputValue && !disabled && ( )}
    ) const renderResultsList = () => ( <>
      {hasGroupedItems ? ( <> {!expandUp && showCreateOption && <>{renderCreateOption(0)}} {inputItems.map((item, index) => { const previousItem = inputItems[index - 1] const hasGroupHeader = item.group && (!previousItem || previousItem.group !== item.group) const displayIndex = getDisplayIndex(index) return ( {hasGroupHeader && index > 0 && (
    • )} {hasGroupHeader && (
    • {item.group}
    • )}
    • {item.label}
    • ) })} {expandUp && showCreateOption && ( <>{renderCreateOption(displayItems.length - 1)} )} ) : ( displayItems.map((item, index) => { if (item.type === 'create') { return ( {renderCreateOption(index)} ) } return (
    • {item.label}
    • ) }) )}
    ) return (
    {expandUp ? ( <> {renderResultsList()} {renderSearchBar()} ) : ( <> {renderSearchBar()} {renderResultsList()} )}
    ) } const OLAutocomplete = forwardRef( (props, ref) => { return } ) OLAutocomplete.displayName = 'OLAutocomplete' export default OLAutocomplete