From 77e7470da47b9433cee7d7a58c2efd89de863550 Mon Sep 17 00:00:00 2001 From: Paul Jankowski <8bittitan@gmail.com> Date: Tue, 30 Jun 2026 16:29:54 -0400 Subject: [PATCH] feat(v5): Add hit result badge (#2901) * feat(v5): Add hit result badge * Add background to hit result badge --- .../demo-react/src/examples/basic-askai.tsx | 1 + packages/docsearch-css/src/modal.css | 15 +++++ .../docsearch-react/src/AskAiScreenState.tsx | 1 + packages/docsearch-react/src/DocSearch.tsx | 10 +++ .../src/DocSearchAskAiModal.tsx | 1 + .../docsearch-react/src/DocSearchModal.tsx | 1 + packages/docsearch-react/src/Results.tsx | 16 +++-- .../docsearch-react/src/ResultsScreen.tsx | 51 ++++++++++++---- packages/docsearch-react/src/ScreenState.tsx | 1 + .../src/components/HitResultBadge.tsx | 58 ++++++++++++++++++ .../utils/__tests__/getNestedValue.test.ts | 61 +++++++++++++++++++ .../src/utils/getNestedValue.ts | 28 +++++++++ packages/docsearch-react/src/utils/index.ts | 1 + 13 files changed, 228 insertions(+), 17 deletions(-) create mode 100644 packages/docsearch-react/src/components/HitResultBadge.tsx create mode 100644 packages/docsearch-react/src/utils/__tests__/getNestedValue.test.ts create mode 100644 packages/docsearch-react/src/utils/getNestedValue.ts diff --git a/examples/demo-react/src/examples/basic-askai.tsx b/examples/demo-react/src/examples/basic-askai.tsx index 7748a28e..063cc7d1 100644 --- a/examples/demo-react/src/examples/basic-askai.tsx +++ b/examples/demo-react/src/examples/basic-askai.tsx @@ -39,6 +39,7 @@ export default function BasicAskAI({ theme }: { theme: DemoTheme }): JSX.Element }, }, }} + resultBadgeKey="type" /> ); } diff --git a/packages/docsearch-css/src/modal.css b/packages/docsearch-css/src/modal.css index f49501e5..f656fa37 100644 --- a/packages/docsearch-css/src/modal.css +++ b/packages/docsearch-css/src/modal.css @@ -952,6 +952,21 @@ svg.DocSearch-Hit-Select-Icon { } } +.DocSearch-Hit-badge { + font-size: 0.75rem; + margin-right: 1em; + display: inline-flex; + padding-inline: 1em; + padding-block: 0.5em; + border-radius: 100vw; + background-color: var(--docsearch-hit-focus-background); +} + +.DocSearch-Hit[aria-selected='true'] .DocSearch-Hit-badge, +.DocSearch-Hit:hover .DocSearch-Hit-badge { + background-color: var(--docsearch-modal-background); +} + /* No Results - Start Screen - Error Screen */ .DocSearch-NoResults, diff --git a/packages/docsearch-react/src/AskAiScreenState.tsx b/packages/docsearch-react/src/AskAiScreenState.tsx index a4d06fa1..1e64abf9 100644 --- a/packages/docsearch-react/src/AskAiScreenState.tsx +++ b/packages/docsearch-react/src/AskAiScreenState.tsx @@ -64,6 +64,7 @@ export interface AskAiScreenStateProps selectSuggestedQuestion: (question: SuggestedQuestionHit) => void; onNewConversation: () => void; memoryEnabled?: boolean; + resultBadgeKey?: string; } export const AskAiScreenState = React.memo( diff --git a/packages/docsearch-react/src/DocSearch.tsx b/packages/docsearch-react/src/DocSearch.tsx index e5ae381a..ed93a7e3 100644 --- a/packages/docsearch-react/src/DocSearch.tsx +++ b/packages/docsearch-react/src/DocSearch.tsx @@ -166,6 +166,16 @@ export interface DocSearchProps { * @default `{ 'Ctrl/Cmd+K': true, '/': true }` */ keyboardShortcuts?: DocSearchModalShortcuts; + /** + * The key used to render a custom badge for each hit. Key must match a property returned in `searchParameters.attributesToRetrieve`. + * + * @example + * "version" + * "hierarchy.lvl1" + * "tags[2]" + * @default undefined + */ + resultBadgeKey?: string; } function DocSearchComponent(props: DocSearchProps, ref: React.ForwardedRef): JSX.Element { diff --git a/packages/docsearch-react/src/DocSearchAskAiModal.tsx b/packages/docsearch-react/src/DocSearchAskAiModal.tsx index df050a1a..0e6502d8 100644 --- a/packages/docsearch-react/src/DocSearchAskAiModal.tsx +++ b/packages/docsearch-react/src/DocSearchAskAiModal.tsx @@ -522,6 +522,7 @@ export function DocSearchAskAiModal({ suggestedQuestions={suggestedQuestions} selectSuggestedQuestion={selectSuggestedQuestion} memoryEnabled={memoryEnabled} + resultBadgeKey={props.resultBadgeKey} onAskAiToggle={onAskAiToggle} onNewConversation={handleNewConversation} onItemClick={(item, event) => { diff --git a/packages/docsearch-react/src/DocSearchModal.tsx b/packages/docsearch-react/src/DocSearchModal.tsx index 493a7a80..b6b9bc88 100644 --- a/packages/docsearch-react/src/DocSearchModal.tsx +++ b/packages/docsearch-react/src/DocSearchModal.tsx @@ -262,6 +262,7 @@ export function DocSearchModal({ translations={screenStateTranslations} getMissingResultsUrl={getMissingResultsUrl} hasCollections={hasCollections} + resultBadgeKey={props.resultBadgeKey} onItemClick={(item, event) => { sendItemClickEvent(item); saveRecentSearch(item); diff --git a/packages/docsearch-react/src/Results.tsx b/packages/docsearch-react/src/Results.tsx index 4641f64e..32688de4 100644 --- a/packages/docsearch-react/src/Results.tsx +++ b/packages/docsearch-react/src/Results.tsx @@ -1,6 +1,7 @@ import type { AutocompleteApi, AutocompleteState, BaseItem } from '@algolia/autocomplete-core'; import React, { type JSX } from 'react'; +import type { HitResultBadgeTranslations } from './components/HitResultBadge'; import { HitContent } from './components/ui/HitContent'; import type { DocSearchProps } from './DocSearch'; import { useRelativeFormattedDate } from './hooks/useRelativeFormattedDate'; @@ -9,11 +10,12 @@ import { Snippet } from './Snippet'; import type { InternalDocSearchHit, StoredDocSearchHit } from './types'; import { decodeHtmlEntities, getHitItemBreadcrumbs } from './utils'; -export type ResultsTranslations = Partial<{ - askAiPlaceholder: string; - noResultsAskAiPlaceholder: string; - recentConversationTimestampFallback: string; -}>; +export type ResultsTranslations = HitResultBadgeTranslations & + Partial<{ + askAiPlaceholder: string; + noResultsAskAiPlaceholder: string; + recentConversationTimestampFallback: string; + }>; interface ResultsProps extends AutocompleteApi { title?: string | null; @@ -21,6 +23,7 @@ interface ResultsProps collection: AutocompleteState['collections'][0]; renderIcon: (props: { item: TItem; index: number }) => React.ReactNode; renderAction: (props: { item: TItem }) => React.ReactNode; + renderResultBadge?: (props: { item: TItem }) => React.ReactNode; onItemClick: (item: TItem, event: KeyboardEvent | MouseEvent) => void; hitComponent: DocSearchProps['hitComponent']; state: AutocompleteState; @@ -100,6 +103,7 @@ function Result({ collection, hitComponent, translations = {}, + renderResultBadge, }: ResultProps): JSX.Element { const Hit = hitComponent!; const { recentConversationTimestampFallback = 'A while ago' } = translations; @@ -140,6 +144,8 @@ function Result({ } subText={breadcrumbs} /> )} + {renderResultBadge?.({ item })} + {renderAction({ item })} diff --git a/packages/docsearch-react/src/ResultsScreen.tsx b/packages/docsearch-react/src/ResultsScreen.tsx index 40c42a4f..61f51dc5 100644 --- a/packages/docsearch-react/src/ResultsScreen.tsx +++ b/packages/docsearch-react/src/ResultsScreen.tsx @@ -1,6 +1,8 @@ import React, { type JSX } from 'react'; +import { HitResultBadge } from './components/HitResultBadge'; import { SelectIcon, SourceIcon } from './icons'; +import type { ResultsTranslations } from './Results'; import { Results } from './Results'; import type { ScreenStateProps } from './ScreenState'; import type { InternalDocSearchHit } from './types'; @@ -9,15 +11,47 @@ export type ResultsScreenTranslations = Partial<{ askAiPlaceholder: string; noResultsAskAiPlaceholder: string; resultsSectionTitle: string; -}>; +}> & + ResultsTranslations; type ResultsScreenProps = Omit, 'translations'> & { translations?: ResultsScreenTranslations; }; -export function ResultsScreen({ translations = {}, ...props }: ResultsScreenProps): JSX.Element { +export function ResultsScreen({ translations = {}, resultBadgeKey, ...props }: ResultsScreenProps): JSX.Element { const { resultsSectionTitle = 'Results' } = translations; + const renderIcon = React.useCallback(({ item }: { item: InternalDocSearchHit }) => { + return ( +
+ +
+ ); + }, []); + + const renderAction = React.useCallback(() => { + return ( +
+ +
+ ); + }, []); + + const renderResultBadge = React.useCallback( + ({ item }: { item: InternalDocSearchHit }) => { + return ( + + ); + }, + [resultBadgeKey, translations.resultBadgeLabelText], + ); + return (
{props.state.collections.map((collection) => { @@ -32,16 +66,9 @@ export function ResultsScreen({ translations = {}, ...props }: ResultsScreenProp translations={translations} title={resultsSectionTitle} collection={collection} - renderIcon={({ item }) => ( -
- -
- )} - renderAction={() => ( -
- -
- )} + renderIcon={renderIcon} + renderAction={renderAction} + renderResultBadge={renderResultBadge} /> ); })} diff --git a/packages/docsearch-react/src/ScreenState.tsx b/packages/docsearch-react/src/ScreenState.tsx index 2fd591af..f55342c9 100644 --- a/packages/docsearch-react/src/ScreenState.tsx +++ b/packages/docsearch-react/src/ScreenState.tsx @@ -34,6 +34,7 @@ export interface ScreenStateProps translations: ScreenStateTranslations; getMissingResultsUrl?: DocSearchProps['getMissingResultsUrl']; hasCollections: boolean; + resultBadgeKey?: string; } export const ScreenState = React.memo( diff --git a/packages/docsearch-react/src/components/HitResultBadge.tsx b/packages/docsearch-react/src/components/HitResultBadge.tsx new file mode 100644 index 00000000..95c489c9 --- /dev/null +++ b/packages/docsearch-react/src/components/HitResultBadge.tsx @@ -0,0 +1,58 @@ +import React, { type JSX } from 'react'; + +import type { InternalDocSearchHit } from '../types'; +import { getNestedValue } from '../utils'; + +export type HitResultBadgeTranslations = Partial<{ + /** + * Label text used for screen readers to announce the result badge. Should reflect + * the key of `resultBadgeKey`. + * + * @default "Category" + */ + resultBadgeLabelText: string; +}>; + +function isRenderablePrimitive(v: unknown): v is boolean | number | string { + return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'; +} + +interface HitResultBadgeProps { + item: InternalDocSearchHit; + resultBadgeKey?: string; + translations?: HitResultBadgeTranslations; +} + +export function HitResultBadge({ item, resultBadgeKey, translations = {} }: HitResultBadgeProps): JSX.Element | null { + if (!resultBadgeKey) { + return null; + } + + const parsedValue = getNestedValue(item, resultBadgeKey); + + if (parsedValue === null || typeof parsedValue === 'undefined') { + return null; + } + + const { resultBadgeLabelText = 'Category' } = translations; + + let badgeValue: string | null = null; + + if (isRenderablePrimitive(parsedValue)) { + badgeValue = String(parsedValue).trim(); + } else if (Array.isArray(parsedValue)) { + const parts = parsedValue.filter(isRenderablePrimitive).map((v) => String(v).trim()); + badgeValue = parts.length > 0 ? parts.join(', ') : null; + } + + if (badgeValue === null || badgeValue.length === 0) { + return null; + } + + return ( +
+ {`${resultBadgeLabelText}: ${badgeValue}`} + +
+ ); +} diff --git a/packages/docsearch-react/src/utils/__tests__/getNestedValue.test.ts b/packages/docsearch-react/src/utils/__tests__/getNestedValue.test.ts new file mode 100644 index 00000000..bcef5984 --- /dev/null +++ b/packages/docsearch-react/src/utils/__tests__/getNestedValue.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { getNestedValue } from '../getNestedValue'; + +const TEST_ITEM = { + hierarchy: { + lvl0: 'Test', + lvl1: 'Another test', + }, + source: { + page: { + title: 'Test Page', + }, + }, + type: 'content', + tags: ['guide', 'framework', 'latest'], +}; + +describe('getNestedValue', () => { + it('returns top level value', () => { + const result = getNestedValue(TEST_ITEM, 'type'); + + expect(result).toEqual('content'); + }); + + it('returns nested value', () => { + const result = getNestedValue(TEST_ITEM, 'hierarchy.lvl1'); + + expect(result).toEqual('Another test'); + }); + + it('returns deeply nested value', () => { + const result = getNestedValue(TEST_ITEM, 'source.page.title'); + + expect(result).toEqual('Test Page'); + }); + + it('returns value at an array index', () => { + let result = getNestedValue(TEST_ITEM, 'tags[1]'); + + expect(result).toEqual('framework'); + + result = getNestedValue(TEST_ITEM, 'tags.2'); + + expect(result).toEqual('latest'); + }); + + it('returns undefined for no found value', () => { + let result = getNestedValue(TEST_ITEM, 'noop'); + + expect(result).toBeUndefined(); + + result = getNestedValue(TEST_ITEM, 'hierarchy.lvl100'); + + expect(result).toBeUndefined(); + + result = getNestedValue(TEST_ITEM, 'tags[10]'); + + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/docsearch-react/src/utils/getNestedValue.ts b/packages/docsearch-react/src/utils/getNestedValue.ts new file mode 100644 index 00000000..693d5d05 --- /dev/null +++ b/packages/docsearch-react/src/utils/getNestedValue.ts @@ -0,0 +1,28 @@ +const segmentsCache = new Map(); + +function parseKey(key: string): string[] { + const cachedKey = segmentsCache.get(key); + if (cachedKey) return cachedKey; + + // Parses the key for array indexing and transforms to `.` delimited + const segments = key + .replace(/\[(\w+)\]/g, '.$1') + .replace(/^\/./, '') + .split('.'); + + segmentsCache.set(key, segments); + + return segments; +} + +export function getNestedValue(obj: unknown, key: string): unknown { + const segments = parseKey(key); + + return segments.reduce((acc, curr) => { + if (acc && typeof acc === 'object' && Object.prototype.hasOwnProperty.call(acc, curr)) { + return (acc as Record)[curr]; + } + + return undefined; + }, obj); +} diff --git a/packages/docsearch-react/src/utils/index.ts b/packages/docsearch-react/src/utils/index.ts index 50f9d7d6..88154a2e 100644 --- a/packages/docsearch-react/src/utils/index.ts +++ b/packages/docsearch-react/src/utils/index.ts @@ -2,6 +2,7 @@ export * from './capitalize'; export * from './collections'; export * from './decodeHtmlEntities'; export * from './getHitItemBreadcrumbs'; +export * from './getNestedValue'; export * from './groupBy'; export * from './identity'; export * from './isModifierEvent';