import './autocomplete.scss'; import { debounce, uniqueId } from 'lodash'; import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState, } from 'react'; import { DndProvider, useDrag, useDrop } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import Icon from '../Icon'; export interface AutoCompleteProps { width?: string; /** * if you need fix height, set to at least "30px" * for Hydro, no less then "34px" can be better */ height?: string; disabled?: boolean; placeholder?: string; disabledHint?: string; listStyle?: React.CSSProperties; cacheKey?: string; renderItem?: (item: Item) => any; queryItems?: (query: string) => Promise | Item[]; fetchItems?: (ids: string[]) => Promise | Item[]; itemText?: (item: Item) => string; itemKey?: (item: Item) => string; onChange?: (value: string) => any; multi?: boolean; draggable?: boolean; selectedKeys?: string[]; allowEmptyQuery?: boolean; freeSolo?: boolean; freeSoloConverter?: (value: string) => string; } export interface AutoCompleteHandle { getSelectedItems: () => Item[]; getSelectedItemKeys: () => string[]; setSelectedItems: (items: Item[]) => void; getQuery: () => string; setQuery: (query: string) => void; setSelectedKeys: (keys: string[]) => void; triggerQuery: () => any; closeList: () => void; getValue: () => string; clear: () => void; focus: () => void; } const superCache = {}; function DraggableSelection({ type, id, move, children, ...props }) { const ref = useRef(null); const [isDragging, drag] = useDrag<{ id: string }, any, boolean>(() => ({ type, item: { id }, collect: (m) => m.isDragging(), })); const [, drop] = useDrop({ accept: type, hover: (item: { id: string }) => { if (!ref.current) return; move(item.id, id); }, }); drag(drop(ref)); return (
{children}
); } // eslint-disable-next-line prefer-arrow-callback const AutoComplete = forwardRef(function Impl(props: AutoCompleteProps, ref: React.Ref>) { const { multi = false, width = '100%', height = 'auto', freeSolo = false, allowEmptyQuery = false, listStyle = {}, // eslint-disable-line @eslint-react/no-unstable-default-props disabled = false, disabledHint = '', draggable = multi, } = props; const queryItems = props.queryItems ?? (() => []); const renderItem = props.renderItem ?? ((item) => item); const itemText = props.itemText ?? ((item) => item.toString()); const itemKey = props.itemKey ?? itemText; const onChange = props.onChange ?? (() => { }); const freeSoloConverter = freeSolo ? props.freeSoloConverter ?? ((i) => i) : ((i) => i); const [focused, setFocused] = useState(false); // is focused const [selectedKeys, setSelectedKeys] = useState(props.selectedKeys || []); // keys of selected items const [itemList, setItemList] = useState([]); // items list const [currentItem, setCurrentItem] = useState(null); // index of current item (in item list) const [rerender, setRerender] = useState(false); const [draggableId] = useState(() => uniqueId()); const inputRef = useRef(null); const listRef = useRef(null); let [queryCache, valueCache] = [useRef({}).current, useRef({}).current]; if (props.cacheKey) { superCache[props.cacheKey] ||= { query: {}, value: {} }; queryCache = superCache[props.cacheKey].query; valueCache = superCache[props.cacheKey].value; } const queryList = async (query) => { if (!query && !allowEmptyQuery) { setItemList([]); setCurrentItem(null); return; } queryCache[query] ||= await queryItems(query); for (const item of queryCache[query]) valueCache[itemKey(item)] = item; setItemList(queryCache[query]); setCurrentItem((!freeSolo && queryCache[query].length) ? 0 : null); }; useEffect(() => { setSelectedKeys(props.selectedKeys || []); }, [JSON.stringify(props.selectedKeys)]); const dispatchChange = () => { if (!multi) onChange(inputRef.current?.value); else onChange(selectedKeys.filter((v) => v?.trim().length).join(',')); }; let first = !multi; useEffect(() => { if (first) first = false; else dispatchChange(); if (!multi) return; // Load pre-selected items only in multi mode const ids = []; for (const key of selectedKeys) if (!valueCache[key]) ids.push(key); if (!ids.length) return; Promise.resolve(props.fetchItems(ids)).then((items) => { for (const item of items) valueCache[itemKey(item)] = item; setRerender(!rerender); }); }, [selectedKeys, multi]); const handleInputChange = debounce((e?) => queryList(e ? e.target.value : ''), 300); const toggleItem = (item: T, key = itemKey(item), preserve = false) => { const shouldKeepOpen = multi && allowEmptyQuery && inputRef.current.value === ''; if (multi) { const idx = selectedKeys.indexOf(key); if (idx !== -1) { setSelectedKeys((s) => { const newSelectedKeys = [...s]; newSelectedKeys.splice(idx, 1); return newSelectedKeys; }); } else { setSelectedKeys((s) => [...s, key]); } if (!preserve) inputRef.current.value = ''; inputRef.current.focus(); } else { setSelectedKeys([key]); inputRef.current.value = key; } dispatchChange(); if (!shouldKeepOpen) { setItemList([]); setCurrentItem(null); } }; const handleInputKeyDown = (e) => { const { key, target } = e; if (key === 'Escape') { setItemList([]); setCurrentItem(null); return; } if (key === 'Enter' || key === ',') { e.preventDefault(); if (currentItem !== null) { toggleItem(itemList[currentItem]); return; } if (freeSolo && target.value !== '') { toggleItem(freeSoloConverter(target.value)); } return; } if (key === 'Backspace') { if (target.value.length) return; if (selectedKeys.length) { setSelectedKeys((s) => s.slice(0, -1)); } return; } if (key === 'ArrowUp') { e.preventDefault(); if (itemList.length === 0) return; const idx = (currentItem ?? 0) - 1; const newIdx = idx < 0 ? itemList.length - 1 : idx; setCurrentItem(newIdx); listRef.current.children[newIdx].scrollIntoView({ behavior: 'smooth', block: 'nearest' }); return; } if (key === 'ArrowDown') { e.preventDefault(); if (itemList.length === 0) return; const idx = (currentItem ?? itemList.length - 1) + 1; const newIdx = idx >= itemList.length ? 0 : idx; setCurrentItem(newIdx); listRef.current.children[newIdx].scrollIntoView({ behavior: 'smooth', block: 'nearest' }); // eslint-disable-next-line no-useless-return return; } // TODO: handle other keys }; useImperativeHandle(ref, () => ({ getSelectedItems: () => selectedKeys.map((key) => valueCache[key]), getSelectedItemKeys: () => [...selectedKeys, inputRef.current?.value].filter((v) => v?.trim().length), setSelectedItems: (items) => { setSelectedKeys(items.map((i) => itemKey(i))); if (!multi && inputRef.current) inputRef.current.value = items.map((i) => itemKey(i)).join(','); }, setSelectedKeys, getQuery: () => inputRef.current?.value, setQuery: (query) => { if (inputRef.current) inputRef.current.value = query; }, triggerQuery: () => queryList(inputRef.current?.value), closeList: () => { setItemList([]); setCurrentItem(null); }, getValue: () => (multi ? selectedKeys.join(',') : (inputRef.current.value ?? '')), clear: () => { setSelectedKeys([]); if (inputRef.current) inputRef.current.value = ''; }, focus: () => { setFocused(true); inputRef.current?.focus(); }, }), [selectedKeys, inputRef, multi]); const move = (dragId: string, hoverId: string) => { if (dragId === hoverId || !draggable) return; const dragIndex = selectedKeys.indexOf(dragId); const hoverIndex = selectedKeys.indexOf(hoverId); setSelectedKeys((s) => { const a = [...s]; a.splice(dragIndex, 1); a.splice(hoverIndex, 0, s[dragIndex]); return a; }); }; return (
{multi && selectedKeys.map((key) => { const item = valueCache[key]; return (
{item ? itemText(item) : key}
toggleItem(item, key, true)} />
); })} { dispatchChange(); handleInputChange(e); }} onFocus={() => { if (allowEmptyQuery) handleInputChange(); setFocused(true); }} onPaste={async (e) => { if (!multi) return; const text = e.clipboardData.getData('text'); if (!text || (!text.includes(',') && !text.includes(','))) return; e.preventDefault(); const ids = text.replace(/,/g, ',').split(',').filter((v) => v?.trim().length && !selectedKeys.includes(v)); if (!ids.length) return; const fetched = await props.fetchItems(ids); for (const item of fetched) valueCache[itemKey(item)] = item; setSelectedKeys([...selectedKeys, ...fetched.map((val) => itemKey(val))]); }} placeholder={props.placeholder} onBlur={() => setFocused(false)} onKeyDown={handleInputKeyDown} defaultValue={multi ? '' : selectedKeys.join(',')} />
{disabled && ( )} {focused && itemList.length > 0 && (
    e.preventDefault()}> {itemList.map((item, idx) => { const inner = renderItem(item); if (!inner) return null; return
  • toggleItem(item)} onMouseMove={() => setCurrentItem(idx)} data-selected={selectedKeys.includes(itemKey(item))} data-focus={idx === currentItem} >
    {inner}
    {selectedKeys.includes(itemKey(item)) && }
  • ; })}
)}
); }) as ((props: AutoCompleteProps & { ref: React.Ref> }) => React.ReactElement) & React.FC; AutoComplete.displayName = 'AutoComplete'; export default AutoComplete;