1
0
Fork 0

feat(v5): Add hit result badge (#2901)

* feat(v5): Add hit result badge

* Add background to hit result badge
This commit is contained in:
Paul Jankowski 2026-06-30 16:29:54 -04:00 committed by GitHub
parent dfc1048417
commit 77e7470da4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 228 additions and 17 deletions

View file

@ -39,6 +39,7 @@ export default function BasicAskAI({ theme }: { theme: DemoTheme }): JSX.Element
},
},
}}
resultBadgeKey="type"
/>
);
}

View file

@ -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,

View file

@ -64,6 +64,7 @@ export interface AskAiScreenStateProps<TItem extends BaseItem>
selectSuggestedQuestion: (question: SuggestedQuestionHit) => void;
onNewConversation: () => void;
memoryEnabled?: boolean;
resultBadgeKey?: string;
}
export const AskAiScreenState = React.memo(

View file

@ -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<DocSearchRef>): JSX.Element {

View file

@ -522,6 +522,7 @@ export function DocSearchAskAiModal({
suggestedQuestions={suggestedQuestions}
selectSuggestedQuestion={selectSuggestedQuestion}
memoryEnabled={memoryEnabled}
resultBadgeKey={props.resultBadgeKey}
onAskAiToggle={onAskAiToggle}
onNewConversation={handleNewConversation}
onItemClick={(item, event) => {

View file

@ -262,6 +262,7 @@ export function DocSearchModal({
translations={screenStateTranslations}
getMissingResultsUrl={getMissingResultsUrl}
hasCollections={hasCollections}
resultBadgeKey={props.resultBadgeKey}
onItemClick={(item, event) => {
sendItemClickEvent(item);
saveRecentSearch(item);

View file

@ -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<TItem extends BaseItem>
extends AutocompleteApi<TItem, React.FormEvent, React.MouseEvent, React.KeyboardEvent> {
title?: string | null;
@ -21,6 +23,7 @@ interface ResultsProps<TItem extends BaseItem>
collection: AutocompleteState<TItem>['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<TItem>;
@ -100,6 +103,7 @@ function Result<TItem extends StoredDocSearchHit>({
collection,
hitComponent,
translations = {},
renderResultBadge,
}: ResultProps<TItem>): JSX.Element {
const Hit = hitComponent!;
const { recentConversationTimestampFallback = 'A while ago' } = translations;
@ -140,6 +144,8 @@ function Result<TItem extends StoredDocSearchHit>({
<HitContent title={<Snippet hit={item} attribute={titleAttribute} />} subText={breadcrumbs} />
)}
{renderResultBadge?.({ item })}
{renderAction({ item })}
</div>
</Hit>

View file

@ -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<ScreenStateProps<InternalDocSearchHit>, '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 (
<div className="DocSearch-Hit-icon">
<SourceIcon type={item.type} />
</div>
);
}, []);
const renderAction = React.useCallback(() => {
return (
<div className="DocSearch-Hit-action">
<SelectIcon />
</div>
);
}, []);
const renderResultBadge = React.useCallback(
({ item }: { item: InternalDocSearchHit }) => {
return (
<HitResultBadge
item={item}
resultBadgeKey={resultBadgeKey}
translations={{
resultBadgeLabelText: translations.resultBadgeLabelText,
}}
/>
);
},
[resultBadgeKey, translations.resultBadgeLabelText],
);
return (
<div className="DocSearch-Dropdown-Container">
{props.state.collections.map((collection) => {
@ -32,16 +66,9 @@ export function ResultsScreen({ translations = {}, ...props }: ResultsScreenProp
translations={translations}
title={resultsSectionTitle}
collection={collection}
renderIcon={({ item }) => (
<div className="DocSearch-Hit-icon">
<SourceIcon type={item.type} />
</div>
)}
renderAction={() => (
<div className="DocSearch-Hit-action">
<SelectIcon />
</div>
)}
renderIcon={renderIcon}
renderAction={renderAction}
renderResultBadge={renderResultBadge}
/>
);
})}

View file

@ -34,6 +34,7 @@ export interface ScreenStateProps<TItem extends BaseItem>
translations: ScreenStateTranslations;
getMissingResultsUrl?: DocSearchProps['getMissingResultsUrl'];
hasCollections: boolean;
resultBadgeKey?: string;
}
export const ScreenState = React.memo(

View file

@ -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 (
<div className="DocSearch-Hit-badge">
<span className="DocSearch-VisuallyHiddenForAccessibility">{`${resultBadgeLabelText}: ${badgeValue}`}</span>
<span aria-hidden="true">{badgeValue}</span>
</div>
);
}

View file

@ -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();
});
});

View file

@ -0,0 +1,28 @@
const segmentsCache = new Map<string, string[]>();
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<unknown>((acc, curr) => {
if (acc && typeof acc === 'object' && Object.prototype.hasOwnProperty.call(acc, curr)) {
return (acc as Record<string, unknown>)[curr];
}
return undefined;
}, obj);
}

View file

@ -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';