diff --git a/web/client/src/library/components/search/SearchList.tsx b/web/client/src/library/components/search/SearchList.tsx index fae23f2303..865270297f 100644 --- a/web/client/src/library/components/search/SearchList.tsx +++ b/web/client/src/library/components/search/SearchList.tsx @@ -281,6 +281,31 @@ export default function SearchList< ) } +function HighlightedText({ + source, + search, +}: { + source: string + search: string +}): JSX.Element { + return ( + <> + {highlightMatch(source, search).map(({ text, isMatch }, idx) => + isMatch ? ( + + {text} + + ) : ( + {text} + ), + )} + + ) +} + function SearchResult = Record>({ item, index, @@ -322,21 +347,23 @@ function SearchResult = Record>({ )} - + + + ) : ( + > + + )} {isNotNil(descriptionBy) && ( diff --git a/web/client/src/library/components/search/help.ts b/web/client/src/library/components/search/help.ts index 5292676475..157377b3a2 100644 --- a/web/client/src/library/components/search/help.ts +++ b/web/client/src/library/components/search/help.ts @@ -3,11 +3,26 @@ export { filterListBy, highlightMatch } export const EMPTY_STRING = '' const MATCHED_LENGTH_TO_DISPLAY = 40 -function highlightMatch(source: string, match: string): string { - return source.replaceAll( - match, - `${match}`, - ) +export interface HighlightPart { + text: string + isMatch: boolean +} + +// NOTE: Returns plain text parts so callers can render them as React text nodes +function highlightMatch(source: string, match: string): HighlightPart[] { + if (match === EMPTY_STRING) return [{ text: source, isMatch: false }] + + return source + .split(match) + .flatMap((text, idx) => + idx === 0 + ? [{ text, isMatch: false }] + : [ + { text: match, isMatch: true }, + { text, isMatch: false }, + ], + ) + .filter(part => part.text !== EMPTY_STRING) } // NOTE: This function is used to filter the list of models in the search bar diff --git a/web/client/src/library/components/search/tests/SearchList.spec.tsx b/web/client/src/library/components/search/tests/SearchList.spec.tsx new file mode 100644 index 0000000000..9eda3db102 --- /dev/null +++ b/web/client/src/library/components/search/tests/SearchList.spec.tsx @@ -0,0 +1,66 @@ +import { describe, it, expect, vi } from 'vitest' +import { MemoryRouter } from 'react-router' +import { render, fireEvent } from '../../../../tests/utils' +import SearchList from '../SearchList' + +// jsdom has no ResizeObserver, which TBKResizeObserver needs to mount +vi.stubGlobal( + 'ResizeObserver', + class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + }, +) + +const PAYLOAD = '' + +function renderSearch( + name: string, + search: string, + showIndex: boolean, +): HTMLElement { + const { container } = render( + + + list={[{ name }]} + searchBy="name" + displayBy="name" + showIndex={showIndex} + /> + , + ) + + fireEvent.input(container.querySelector('input')!, { + target: { value: search }, + }) + + return container.querySelector('[role="menuitem"]')! +} + +describe.each([true, false])('SearchList with showIndex=%s', showIndex => { + it('should render markup in project text as text', () => { + const name = `${PAYLOAD}model` + const result = renderSearch(name, 'model', showIndex) + + expect(result.querySelector('img')).toBeNull() + expect(result.textContent).toContain(name) + expect(result.querySelector('b')?.textContent).toBe('model') + }) + + it('should render markup in the search string as text', () => { + const result = renderSearch(`a${PAYLOAD}b`, PAYLOAD, showIndex) + + expect(result.querySelector('img')).toBeNull() + expect(result.querySelector('b')?.textContent).toBe(PAYLOAD) + }) + + it('should highlight every match, including adjacent and trailing ones', () => { + const result = renderSearch('ababxab', 'ab', showIndex) + + expect( + Array.from(result.querySelectorAll('b'), b => b.textContent), + ).toEqual(['ab', 'ab', 'ab']) + expect(result.textContent).toContain('ababxab') + }) +})