import { getSearchReg } from '@vev/utils'; import { isEqual, isUndefined } from 'lodash'; import React, { Children, ReactNode, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { useFormField, useInterval, useParentScroll, useWindowResize } from '../../hooks'; import { SilkeBox } from '../silke-box'; import { SilkeDivider } from '../silke-divider'; import { FormValidator } from '../silke-form'; import { SilkeIcon } from '../silke-icon'; import { PopoverOrigin, SilkePopover } from '../silke-popover'; import { SilkeTextSmall } from '../silke-text'; import { SilkeTextField, SilkeTextFieldItem } from '../silke-text-field'; import { useTextFieldContext } from '../silke-text-field/text-field-context'; import AutocompleteItem from './autocomplete-item'; import { Data, Entry } from './autocomplete-types'; import styles from './silke-autocomplete-field.scss'; import { SilkeAutocompleteFieldProps } from './silke-autocomplete-field'; import { SilkeButton } from '../silke-button'; /** * Props for autocomplete popup */ type SilkeAutocompletePopupProps = SilkeAutocompleteFieldProps & { containerRef?: React.RefObject; searchPlaceholder?: string; onRequestClose?: () => void; }; const autocompleteValidator: FormValidator = (props, value) => { if (props.multiple) { const len = value?.length || 0; if (len > (props.maxItems || 1000)) return `To many item, max ${props.maxItems}`; if (len < (props.minItems || 0)) return `To few item, min ${props.minItems}`; } if (props.required && !value && value !== 0) return 'Required'; return undefined; }; /** * Checks if any of an `Entry`'s fields match the query * @param entry * @param search Text to search for */ const filter = ({ label, subLabel, value }: Entry, search: RegExp): boolean => (value && value.toString && search.test(value.toString() + '')) || (label ? search.test(label) : false) || (subLabel ? search.test(subLabel) : false); const normalizeString = (str?: string | number): string => str ? str.toString().toLowerCase() : ''; /** * Checks if any of an `Entry`'s fields match the query * @param entry * @param searchString Text string to search for */ const filterString = ({ label, subLabel, value }: Entry, searchString: string): boolean => { const normalizedSearchString = normalizeString(searchString); return ( normalizeString(label).startsWith(normalizedSearchString) || normalizeString(value).startsWith(normalizedSearchString) || normalizeString(subLabel).startsWith(normalizedSearchString) ); }; const sorter: SilkeAutocompletePopupProps['sort'] = () => 0; const putMatchingItemsOnTop = (filteredItems: Entry[], searchString: string): Entry[] => { const itemsWithMatch = filteredItems.filter((item) => filterString(item, searchString)); const itemsWithNoMatch = filteredItems.filter((item) => !filterString(item, searchString)); return [...itemsWithMatch, ...itemsWithNoMatch]; }; type GroupedEntries = { groupLabel?: string; items: Entry[] }; function groupEntriesByLabel(entries: Entry[]): GroupedEntries[] { const grouped: GroupedEntries[] = []; let currentGroup: GroupedEntries | null = null; entries.forEach((entry) => { const groupLabel = !entry.divider ? (entry as Data).groupLabel : undefined; if (!currentGroup || currentGroup.groupLabel !== groupLabel) { currentGroup = { groupLabel, items: [] }; grouped.push(currentGroup); } currentGroup.items.push(entry); }); return grouped; } export const useMatchingItemsOnTop = ( filteredItems: Entry[], searchString?: string, selectedLabel?: string, ): Entry[] => { return useMemo( () => !searchString || searchString === selectedLabel ? filteredItems : putMatchingItemsOnTop(filteredItems, searchString), [searchString, filteredItems, selectedLabel], ); }; export function SilkeAutocompletePopup(props: SilkeAutocompletePopupProps) { const { disableSearch, disableAddFromList, selectedVariableKey, containerRef, items, onAdd, renderItem: ItemRender, sort = sorter, label, anchorOrigin, targetOrigin, maxDisplayedItems = 150, dropDownMinWidth, dropDownMaxWidth, onSearch, onClose, onRequestClose, buttonRight, searchPlaceholder, ...form } = useFormField(props, autocompleteValidator); const context = useTextFieldContext(); const size = form.size || context.size || 'base'; const inputField = useRef(); const isSelected = useCallback( (value: T): boolean => { if (form.multiple) { const values = Array.isArray(form.value) ? form.value : []; for (const v of values) { if (isEqual(v, value) || isEqual(value, selectedVariableKey)) return true; } return false; } return isEqual(value, form.value) || isEqual(value, selectedVariableKey); }, [form.multiple, form.value, selectedVariableKey], ); function getIndexInDir( currentIndex: number, listLength: number, dir: 'ArrowUp' | 'ArrowDown', ): number { if (dir === 'ArrowUp') { return currentIndex === 0 ? listLength - 1 : currentIndex - 1; } return currentIndex === listLength - 1 ? 0 : currentIndex + 1; } const [selectedLabel] = useMemo((): [label?: string] => { if (form.multiple) return []; const entry = items.find((entry) => !entry.divider && isEqual(entry.value, form.value)); return [entry?.label || (form.value && form.value.toString()) || '']; }, [items, form.multiple, form.value]); const [searchText, _setSearchText] = useState(); const [prevSearchText, setPrevSearchText] = useState(searchText); const inputRef = useRef(); const dropdownRef = useRef(); const [highlight, setHighlight] = useState(undefined); const before: ReactNode[] = []; const after: ReactNode[] = []; Children.forEach(form.children, (child) => { if (React.isValidElement(child) && child.type === SilkeTextFieldItem && child.props.before) { before.push(child); } else { after.push(child); } }); /** * Keep track of previous value, so we know when to remove an item in the list */ const setSearchText = useCallback((value: string | undefined) => { _setSearchText((previousValue) => { setPrevSearchText(previousValue); return value; }); }, []); /** * Set text field to display selected value */ const resetSearchText = useCallback(() => { // Update display of selected item setSearchText(''); }, []); useEffect(() => { if (!form.multiple && !isUndefined(form.value)) { resetSearchText(); } }, [form.multiple, resetSearchText, form.value]); useEffect(() => { if (highlight !== undefined) { dropdownRef.current?.children.item(highlight)?.scrollIntoView(false); } }, [highlight]); const handleChange = (nuValue: T | undefined) => { if (form.multiple) { form.onChange([...(Array.isArray(form.value) ? form.value : []), nuValue]); } else { form.onChange(nuValue as any); } }; const handleRemoveSingle = (remove: T) => { if (form.multiple && form.value) { const value = Array.isArray(form.value) ? form.value.filter((v: T) => remove !== v) : []; if (!isEqual(value, form.value)) form.onChange(value); } }; const searchReg = useMemo(() => { return searchText && searchText !== selectedLabel ? getSearchReg(searchText) : undefined; }, [searchText, selectedLabel]); const filteredItems = useMemo(() => { return searchReg ? items.filter((entry) => filter(entry, searchReg)) : items; }, [searchReg, items]); const itemsWithMatchingOnTop = useMatchingItemsOnTop(filteredItems, searchText, selectedLabel); const [displayedItems, isHidingResults]: [Entry[], boolean] = useMemo(() => { return [ itemsWithMatchingOnTop.slice(0, maxDisplayedItems), itemsWithMatchingOnTop.length > maxDisplayedItems, ]; }, [itemsWithMatchingOnTop, maxDisplayedItems]); const sortedItems = useMemo[]>( () => [...displayedItems].sort(sort), [displayedItems, sort], ); const groupedItems = useMemo(() => groupEntriesByLabel(sortedItems), [sortedItems]); const navigableItems = useMemo( () => sortedItems.filter((entry) => !entry.divider) as Data[], [sortedItems], ); const isItemSelected = (item: string): boolean => { return ( Array.isArray(form.value) && form.value.map((s) => String(s).toUpperCase()).includes(item.trim().toUpperCase()) ); }; const findItem = (item: string) => { return filteredItems.find((listItem) => { return (listItem.label || listItem.value).toUpperCase() === item.toUpperCase().trim(); }); }; const [overridePopoverPlacement, setOverridePopoverPlacement] = useState<{ anchor: PopoverOrigin; target: PopoverOrigin; }>(); const updatePlacement = () => { if (containerRef && containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); const height = window.innerHeight; const spaceUnderElement = height - rect.top - rect.height; if (spaceUnderElement < 300) { setOverridePopoverPlacement({ anchor: 'top-left', target: 'bottom-left', }); } else { setOverridePopoverPlacement(undefined); } } }; useInterval(updatePlacement, 1000); useParentScroll(containerRef, updatePlacement); useWindowResize(updatePlacement); const getIndexOfFirstSelectedItem = useCallback(() => { for (let i = 0; i < navigableItems.length; i++) { if (isSelected(navigableItems[i].value)) { return i; } } return 0; }, [isSelected, navigableItems]); useEffect(() => { if (highlight === undefined) { const indexOfFirstSelectedItem = getIndexOfFirstSelectedItem(); setHighlight(indexOfFirstSelectedItem); } }, [getIndexOfFirstSelectedItem, highlight]); const handleSelect = (item?: Entry) => { inputField?.current?.focus(); if (!item) { resetSearchText(); } else if (!item.divider) { if (form.multiple) { if (isSelected(item.value)) handleRemoveSingle(item.value); else handleChange(item.value); resetSearchText(); } else { resetSearchText(); handleChange(item.value); onRequestClose && onRequestClose(); onClose && onClose(); setHighlight(undefined); } } }; const handleKeyUp = (e: React.KeyboardEvent) => { switch (e.key) { case 'Backspace': { if (prevSearchText?.length === 1) { setPrevSearchText(''); } else if (form.multiple && form.value && !searchText) { if (Array.isArray(form.value)) form.onChange(form.value.slice(0, -1)); } break; } case 'Enter': { if (form.multiple && onAdd && searchText) { if (!isItemSelected(searchText)) { const item = findItem(searchText); if (!item) { onAdd(searchText); } else { handleChange(item.value); } } setSearchText(''); setPrevSearchText(''); } else { const item = highlight !== undefined && navigableItems[highlight]; if (item) { handleSelect(item); } else { if (onAdd && searchText) { onAdd(searchText); } } } inputField.current?.focus(); onRequestClose && onRequestClose(); onClose && onClose(); break; } case 'ArrowUp': case 'ArrowDown': { e.preventDefault(); e.stopPropagation(); const { key } = e; setHighlight((value) => getIndexInDir(value || 0, navigableItems.length, key)); break; } case 'Escape': { inputField.current?.focus(); onRequestClose && onRequestClose(); onClose && onClose(); break; } } }; const dataCy = props['data-cy'] || undefined; const iconFontSize = size === 's' ? '12px' : undefined; // Only match anchor width if dropDownMinWidth not defined or container width is larger than dropDownMinWidth const matchAnchorWidth = !dropDownMinWidth || (!!containerRef?.current && containerRef?.current?.clientWidth > dropDownMinWidth); return ( { onRequestClose && onRequestClose(); onClose && onClose(); }} > } aria-label={'Search for ' + label} autoFocus size={size} className={disableSearch ? styles.hiddenSearch : undefined} placeholder={searchPlaceholder || 'Search'} kind="ghost" onFocus={(e) => { inputRef.current = e.currentTarget as HTMLInputElement; if (props.onFocus) props.onFocus(e); }} onBlur={(e) => { if (props.onBlur) props.onBlur(e); }} onChange={(text: string) => { onSearch?.(text); setHighlight(0); setSearchText(text); }} onKeyUp={handleKeyUp} onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Tab') { inputField.current?.focus(); } if (['Enter', 'ArrowUp', 'ArrowDown', 'Escape'].includes(e.key)) { e.preventDefault(); e.stopPropagation(); } }} value={searchText} > {props.showAddIcon && onAdd && ( onAdd('variable')} /> )} {buttonRight &&
onRequestClose?.()}>{buttonRight}
}
{!disableSearch && } {displayedItems.length === 0 && ( No results )} {(() => { let navigableIndex = 0; return groupedItems.map((group, groupIdx) => ( {group.groupLabel && ( {group.groupLabel} )} {group.items.map((item, itemIdx) => { if (item.divider) { return ; } const currentIndex = navigableIndex++; return ( { e.preventDefault(); e.stopPropagation(); if (!item.disabled) handleSelect(item); }} /> ); })} )); })()} {onAdd && !disableAddFromList && searchText && ( { e.preventDefault(); onAdd(searchText); setSearchText(''); }} /> )} {isHidingResults ? props.maxDisplayedItemsLabel || ( <> Currently displaying the {maxDisplayedItems} first results, please enter a more specific query to see the rest ) : null}
); }