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')
+ })
+})