diff --git a/packages/core/src/lib/select/__tests__/search.spec.ts b/packages/core/src/lib/select/__tests__/search.spec.ts new file mode 100644 index 000000000..06ccf98c1 --- /dev/null +++ b/packages/core/src/lib/select/__tests__/search.spec.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; +import { + getSearchHighlight, + getSearchMatch, + getSearchMatches, + getSearchResults, + getWordIndex, + prioritizeMatches, +} from '../search'; +import { escapeRegExp } from 'lodash'; + +describe('search', () => { + const options = [ + 'First Option', + 'Option 2', + 'C.) Option', + 'Something Else', + 'With A Whole Lot Of Parts', + ]; + + describe('getWordIndex', () => { + it('should return the index of the word containing the character at the passed index', () => { + const option = 'With A Whole Lot Of Parts'; + const index = option.indexOf('c'); + expect(getWordIndex(option, index)).toBe(5); + }); + }); + + describe('getSearchHighlight', () => { + const option = 'Test Option'; + + it('should return a highlighting breakdown of the passed option with a term matching the start of a word', () => { + const search = 'Opt'; + const match = new RegExp(escapeRegExp(search), 'giu').exec(option); + const highlight = getSearchHighlight(option, search, match!); + + expect(highlight?.[0]).toBe('Test '); + expect(highlight?.[1]).toBe(search); + expect(highlight?.[2]).toBe('ion'); + }); + + it('should return a highlighting breakdown of the passed option with a term matching anywhere', () => { + const search = 'pt'; + const match = new RegExp(escapeRegExp(search), 'giu').exec(option); + const highlight = getSearchHighlight(option, search, match!); + + expect(highlight?.[0]).toBe('Test O'); + expect(highlight?.[1]).toBe(search); + expect(highlight?.[2]).toBe('ion'); + }); + }); + + describe('getSearchMatch', () => { + const option = 'Test Option'; + + it('should return a priority 0 match (start of word)', () => { + const search = 'Opt'; + const match = getSearchMatch(option, search); + + expect(match.priority).toBe(0); + }); + + it('should return a priority 1 match (in first word)', () => { + const search = 'est'; + const match = getSearchMatch(option, search); + + expect(match.priority).toBe(1); + }); + + it('should return a priority 2 match (in second word)', () => { + const search = 'pt'; + const match = getSearchMatch(option, search); + + expect(match.priority).toBe(2); + }); + + it('should return a priority -1 match (no match)', () => { + const search = 'rf'; + const match = getSearchMatch(option, search); + + expect(match.priority).toBe(-1); + }); + }); + + describe('prioritizeMatches', () => { + it('should return a prioritized map of matches', () => { + const matches = prioritizeMatches(options, 's'); + + expect(matches['-1'].length).toBe(2); + expect(matches['0'].length).toBe(1); + expect(matches['1']?.length).toBe(1); + expect(matches['6']?.length).toBe(1); + }); + }); + + describe('getSearchMatches', () => { + it('should return an array of search matches sorted by their priority', () => { + const matches = getSearchMatches(options, 's', false); + + expect(matches[0]?.priority).toBe(0); + expect(matches[1]?.priority).toBe(1); + expect(matches[2]?.priority).toBe(6); + expect(matches[3]?.priority).toBe(-1); + expect(matches[4]?.priority).toBe(-1); + }); + + it('should return an array of search matches sorted by their priority without any non-matches', () => { + const matches = getSearchMatches(options, 's', true); + + expect(matches[0]?.priority).toBe(0); + expect(matches[1]?.priority).toBe(1); + expect(matches[2]?.priority).toBe(6); + expect(matches[3]).toBe(undefined); + expect(matches[4]).toBe(undefined); + }); + }); + + describe('getSearchResults', () => { + it('should return an array of search results sorted by priority', () => { + const matches = getSearchResults(options, 's', false); + + expect(matches[0]?.option).toBe('Something Else'); + expect(matches[1]?.option).toBe('First Option'); + expect(matches[2]?.option).toBe('With A Whole Lot Of Parts'); + expect(matches[3]?.option).toBe('Option 2'); + expect(matches[4]?.option).toBe('C.) Option'); + }); + + it('should return an array of search results sorted by priority without any non-matches', () => { + const matches = getSearchResults(options, 's', true); + + expect(matches[0]?.option).toBe('Something Else'); + expect(matches[1]?.option).toBe('First Option'); + expect(matches[2]?.option).toBe('With A Whole Lot Of Parts'); + expect(matches[3]).toBe(undefined); + expect(matches[4]).toBe(undefined); + }); + }); +}); diff --git a/packages/core/src/lib/select/search.ts b/packages/core/src/lib/select/search.ts index cae106c61..8b9d63f43 100644 --- a/packages/core/src/lib/select/search.ts +++ b/packages/core/src/lib/select/search.ts @@ -65,7 +65,7 @@ export type SearchMatches = { * Returns the index of the word where the character with the passed index * occurs. */ -const getWordIndex = (option: string, index: number) => { +export const getWordIndex = (option: string, index: number) => { const preceding = option.slice(0, index); return (preceding.match(/\s/gu) || []).length; }; @@ -74,16 +74,15 @@ const getWordIndex = (option: string, index: number) => { * Returns the passed option broken down into segments to apply highlighting * to the portion of the option that matches the passed search term. */ -const getSearchHighlight = ( +export const getSearchHighlight = ( option: string, searchTerm: string, { index }: RegExpExecArray ): SearchHighlight | undefined => { - const matchIndex = index === 0 ? 0 : index + 1; return [ - option.slice(0, matchIndex), - option.slice(matchIndex, matchIndex + searchTerm.length), - option.slice(matchIndex + searchTerm.length), + option.slice(0, index), + option.slice(index, index + searchTerm.length), + option.slice(index + searchTerm.length), ]; }; @@ -94,7 +93,10 @@ const getSearchHighlight = ( * - The index of the word in the option that matched * - `-1` for non-matches */ -const getSearchMatch = (option: string, searchTerm: string): SearchMatch => { +export const getSearchMatch = ( + option: string, + searchTerm: string +): SearchMatch => { // Match on the initial character of any word in the option const initialCharacterMatch = new RegExp( `(^${searchTerm}|\\s${searchTerm})`, @@ -136,7 +138,7 @@ const getSearchMatch = (option: string, searchTerm: string): SearchMatch => { * Checks each passed option if it matches with the passed search term, and * returns a prioritized map of the results. */ -const prioritizeMatches = (options: string[], searchTerm: string) => { +export const prioritizeMatches = (options: string[], searchTerm: string) => { const results: SearchMatches = { '0': [], '-1': [], @@ -157,7 +159,11 @@ const prioritizeMatches = (options: string[], searchTerm: string) => { * highlighting breakdown for the match. If reduce is true, options * with no match (-1 priority) will not be included in the results. */ -const getMatches = (options: string[], searchTerm: string, reduce: boolean) => { +export const getSearchMatches = ( + options: string[], + searchTerm: string, + reduce: boolean +) => { const prioritized = prioritizeMatches(options, searchTerm); const results: SearchMatch[] = []; @@ -174,16 +180,6 @@ const getMatches = (options: string[], searchTerm: string, reduce: boolean) => { return results; }; -/** - * Sorts the passed results by the `highlight` segment of the result. - */ -const sortByHighlight = (results: SearchMatch[]) => - results.sort((a, b) => { - const aIndex = a.highlight?.[1] ?? -1; - const bIndex = b.highlight?.[1] ?? -1; - return aIndex < bIndex ? -1 : 1; - }); - /** * Searches against the passed options with the passed search term before * sorting them and breaking them down into segments to allow for highlighting. @@ -209,7 +205,7 @@ export const getSearchResults = ( const matches: SearchMatch[] = []; const noMatches = []; const escaped = escapeRegExp(searchTerm); - const results = getMatches(options, escaped, reduce); + const results = getSearchMatches(options, escaped, reduce); for (const match of results) { if (match.highlight === undefined) { @@ -220,6 +216,5 @@ export const getSearchResults = ( matches.push(match); } - const sorted = sortByHighlight(matches); - return [...sorted, ...noMatches]; + return [...matches, ...noMatches]; };