import { useCallback, useEffect, useMemo, useState } from 'react'; import { MatchHandler, AsyncSearch, AsyncSearchHandler, AsyncSearchOption, MatchQueryOption, NormalizeOption, normalize, matchQuery, ResultHandler, } from '../utils/AsyncSearch'; import { sanitizeForRegex } from '../utils/regex'; export type UseAsyncSearchOptions = AsyncSearchOption & { matchOptions?: MatchQueryOption; normalizeOptions?: NormalizeOption; }; export type SearchItemStrGetter = ( searchItem: TSearchItem, query: string ) => string | string[]; export type UseAsyncSearchResult = { query: string; items: TSearchItem[]; }; export type SearchResetHandler = () => void; const performMatch = ( target: string | string[], query: string, options?: UseAsyncSearchOptions ): string | undefined => { if (Array.isArray(target)) { const matchTarget = target.find((i) => matchQuery(normalize(i, options?.normalizeOptions), query, options?.matchOptions) ); return matchTarget ? normalize(matchTarget, options?.normalizeOptions) : undefined; } const normalizedTargetStr = normalize(target, options?.normalizeOptions); const matches = matchQuery(normalizedTargetStr, query, options?.matchOptions); return matches ? normalizedTargetStr : undefined; }; export const orderSearchItems = ( query: string, items: TSearchItem[], getItemStr: SearchItemStrGetter, options?: UseAsyncSearchOptions ): TSearchItem[] => { const orderedItems: TSearchItem[] = Array.from(items); // we will consider "_" as word boundary char. // because in more use-cases it is used. (like: emojishortcode) const boundaryRegex = new RegExp(`(\\b|_)${sanitizeForRegex(query)}`); const perfectBoundaryRegex = new RegExp(`(\\b|_)${sanitizeForRegex(query)}(\\b|_)`); orderedItems.sort((i1, i2) => { const str1 = performMatch(getItemStr(i1, query), query, options); const str2 = performMatch(getItemStr(i2, query), query, options); if (str1 === undefined && str2 === undefined) return 0; if (str1 === undefined) return 1; if (str2 === undefined) return -1; let points1 = 0; let points2 = 0; // short string should score more const pointsToSmallStr = (points: number) => { if (str1.length < str2.length) points1 += points; else if (str2.length < str1.length) points2 += points; }; pointsToSmallStr(1); // closes query match should score more const indexIn1 = str1.indexOf(query); const indexIn2 = str2.indexOf(query); if (indexIn1 < indexIn2) points1 += 2; else if (indexIn2 < indexIn1) points2 += 2; else pointsToSmallStr(2); // query match word start on boundary should score more const boundaryIn1 = str1.match(boundaryRegex); const boundaryIn2 = str2.match(boundaryRegex); if (boundaryIn1 && boundaryIn2) pointsToSmallStr(4); else if (boundaryIn1) points1 += 4; else if (boundaryIn2) points2 += 4; // query match word start and end on boundary should score more const perfectBoundaryIn1 = str1.match(perfectBoundaryRegex); const perfectBoundaryIn2 = str2.match(perfectBoundaryRegex); if (perfectBoundaryIn1 && perfectBoundaryIn2) pointsToSmallStr(8); else if (perfectBoundaryIn1) points1 += 8; else if (perfectBoundaryIn2) points2 += 8; return points2 - points1; }); return orderedItems; }; export const useAsyncSearch = ( list: TSearchItem[], getItemStr: SearchItemStrGetter, options?: UseAsyncSearchOptions ): [UseAsyncSearchResult | undefined, AsyncSearchHandler, SearchResetHandler] => { const [result, setResult] = useState>(); const [searchCallback, terminateSearch] = useMemo(() => { setResult(undefined); const handleMatch: MatchHandler = (item, query) => { const itemStr = getItemStr(item, query); const strWithMatch = performMatch(itemStr, query, options); return typeof strWithMatch === 'string'; }; const handleResult: ResultHandler = (results, query) => setResult({ query, items: orderSearchItems(query, results, getItemStr, options), }); return AsyncSearch(list, handleMatch, handleResult, options); }, [list, options, getItemStr]); const searchHandler: AsyncSearchHandler = useCallback( (query) => { const normalizedQuery = normalize(query, options?.normalizeOptions); searchCallback(normalizedQuery); }, [searchCallback, options?.normalizeOptions] ); const resetHandler: SearchResetHandler = useCallback(() => { terminateSearch(); setResult(undefined); }, [terminateSearch]); useEffect( () => () => { // terminate any ongoing search request on unmount. terminateSearch(); }, [terminateSearch] ); return [result, searchHandler, resetHandler]; };