feat(v5): Add hit result badge (#2901)
* feat(v5): Add hit result badge * Add background to hit result badge
This commit is contained in:
parent
dfc1048417
commit
77e7470da4
13 changed files with 228 additions and 17 deletions
|
|
@ -39,6 +39,7 @@ export default function BasicAskAI({ theme }: { theme: DemoTheme }): JSX.Element
|
|||
},
|
||||
},
|
||||
}}
|
||||
resultBadgeKey="type"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -522,6 +522,7 @@ export function DocSearchAskAiModal({
|
|||
suggestedQuestions={suggestedQuestions}
|
||||
selectSuggestedQuestion={selectSuggestedQuestion}
|
||||
memoryEnabled={memoryEnabled}
|
||||
resultBadgeKey={props.resultBadgeKey}
|
||||
onAskAiToggle={onAskAiToggle}
|
||||
onNewConversation={handleNewConversation}
|
||||
onItemClick={(item, event) => {
|
||||
|
|
|
|||
|
|
@ -262,6 +262,7 @@ export function DocSearchModal({
|
|||
translations={screenStateTranslations}
|
||||
getMissingResultsUrl={getMissingResultsUrl}
|
||||
hasCollections={hasCollections}
|
||||
resultBadgeKey={props.resultBadgeKey}
|
||||
onItemClick={(item, event) => {
|
||||
sendItemClickEvent(item);
|
||||
saveRecentSearch(item);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export interface ScreenStateProps<TItem extends BaseItem>
|
|||
translations: ScreenStateTranslations;
|
||||
getMissingResultsUrl?: DocSearchProps['getMissingResultsUrl'];
|
||||
hasCollections: boolean;
|
||||
resultBadgeKey?: string;
|
||||
}
|
||||
|
||||
export const ScreenState = React.memo(
|
||||
|
|
|
|||
58
packages/docsearch-react/src/components/HitResultBadge.tsx
Normal file
58
packages/docsearch-react/src/components/HitResultBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
28
packages/docsearch-react/src/utils/getNestedValue.ts
Normal file
28
packages/docsearch-react/src/utils/getNestedValue.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue