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 } from '../silke-popover'; import { SilkePopoverNative } from '../silke-popover-native'; import type { PopoverPlacement } from '../silke-popover-native/types'; 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'; /** * Converts PopoverOrigin to PopoverPlacement */ function convertOriginToPlacement( anchorOrigin?: PopoverOrigin, targetOrigin?: PopoverOrigin, ): PopoverPlacement { // Default to bottom-start (equivalent to bottom-left anchor, top-left target) if (!anchorOrigin && !targetOrigin) return 'bottom-start'; // Map common combinations if (anchorOrigin?.startsWith('bottom-')) { if (anchorOrigin === 'bottom-left') return 'bottom-start'; if (anchorOrigin === 'bottom-center') return 'bottom'; if (anchorOrigin === 'bottom-right') return 'bottom-end'; } if (anchorOrigin?.startsWith('top-')) { if (anchorOrigin === 'top-left') return 'top-start'; if (anchorOrigin === 'top-center') return 'top'; if (anchorOrigin === 'top-right') return 'top-end'; } return 'bottom-start'; } /** * Props for autocomplete popup */ type SilkeAutocompletePopupNativeProps = SilkeAutocompleteFieldProps & { containerRef?: React.RefObject; searchPlaceholder?: string; onRequestClose?: () => void; hide?: boolean; }; 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 */ 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() : ''; 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: SilkeAutocompletePopupNativeProps['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]; }; export const useMatchingItemsOnTop = ( filteredItems: Entry[], searchString?: string, selectedLabel?: string, ): Entry[] => { return useMemo( () => !searchString || searchString === selectedLabel ? filteredItems : putMatchingItemsOnTop(filteredItems, searchString), [searchString, filteredItems, selectedLabel], ); }; export function SilkeAutocompletePopupNative(props: SilkeAutocompletePopupNativeProps) { const { disableSearch, disableAddFromList, selectedVariableKey, containerRef, items, onAdd, renderItem: ItemRender, sort = sorter, label, anchorOrigin, targetOrigin, maxDisplayedItems = 150, dropDownMinWidth, dropDownMaxWidth, onSearch, onClose, onRequestClose, buttonRight, searchPlaceholder, hide = false, ...form } = useFormField(props, autocompleteValidator); const context = useTextFieldContext(); const size = form.size || context.size || 'base'; const inputField = useRef(); const isSelected = (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); }; 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); } }); const setSearchText = useCallback((value: string | undefined) => { _setSearchText((previousValue) => { setPrevSearchText(previousValue); return value; }); }, []); const resetSearchText = useCallback(() => { 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 filteredDataEntries = displayedItems.filter((entry) => !entry.divider) as Data[]; 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 [overridePlacement, setOverridePlacement] = useState(); 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) { setOverridePlacement('top-start'); } else { setOverridePlacement(undefined); } } }; useInterval(updatePlacement, 1000); useParentScroll(containerRef, updatePlacement); useWindowResize(updatePlacement); const getIndexOfFirstSelectedItem = () => { for (let i = 0; i < filteredItems.length; i++) { if (isSelected(filteredItems[i].value)) { return i; } } return 0; }; useEffect(() => { if (highlight === undefined) { const indexOfFirstSelectedItem = getIndexOfFirstSelectedItem(); setHighlight(indexOfFirstSelectedItem); } }, []); 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 && filteredDataEntries[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, filteredDataEntries.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; const matchAnchorWidth = !dropDownMinWidth || (!!containerRef?.current && containerRef?.current?.clientWidth > dropDownMinWidth); const placement = overridePlacement || convertOriginToPlacement(anchorOrigin, targetOrigin); const handleRequestClose = () => { onRequestClose && onRequestClose(); onClose && onClose(); }; return ( } 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 )} {(() => { const sortedItems = displayedItems.sort(sort); const groupedItems: { groupLabel?: string; items: Entry[] }[] = []; let currentGroup: { groupLabel?: string; items: Entry[] } | null = null; sortedItems.forEach((item) => { const itemGroupLabel = !item.divider ? (item as Data).groupLabel : undefined; if (!currentGroup || currentGroup.groupLabel !== itemGroupLabel) { currentGroup = { groupLabel: itemGroupLabel, items: [] }; groupedItems.push(currentGroup); } currentGroup.items.push(item); }); let itemIndex = 0; return groupedItems.map((group, groupIdx) => ( {group.groupLabel && ( {group.groupLabel} )} {group.items.map((item) => { const currentItemIndex = itemIndex++; return item.divider ? ( ) : ( { e.preventDefault(); e.stopPropagation(); if (!item.divider && !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}
); }