feat(v5): Add hit breadcrumbs (#2897)
* feat(v5): UI updates * fix: css file size * fix: e2e tests * fix: e2e tests * fix: e2e tests * chore: add theme toggle to react demo example * Update sources panel display, update dark theme * fix: lint * fix(askai): address ui review feedback * fix: pin icon positioning * fix(askai): improve a11y and dark-mode shimmer for thinking and error states - add role=alert/status and aria-hidden on error/thinking UI - support dark-mode shimmer gradients via CSS variables - respect prefers-reduced-motion for shimmer - handle null date in useRelativeFormattedDate with fallback translation * feat(v5): Add hit breadcrumbs * fix: bump css bundle size limit * Fix after conflicts
This commit is contained in:
parent
0da291bb95
commit
2b529940e2
8 changed files with 221 additions and 38 deletions
|
|
@ -642,6 +642,22 @@ svg.DocSearch-Hit-Select-Icon {
|
|||
gap: 4px;
|
||||
}
|
||||
|
||||
|
||||
.DocSearch-Hit-title,
|
||||
.DocSearch-Hit-path {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.DocSearch-Hit-title,
|
||||
.DocSearch-Hit-path {
|
||||
max-width: 584px;
|
||||
}
|
||||
}
|
||||
|
||||
.DocSearch-Hit-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import type { AutocompleteApi, AutocompleteState, BaseItem } from '@algolia/autocomplete-core';
|
||||
import React, { type JSX } from 'react';
|
||||
|
||||
import { HitContent } from './components/ui/HitContent';
|
||||
import type { DocSearchProps } from './DocSearch';
|
||||
import { useRelativeFormattedDate } from './hooks/useRelativeFormattedDate';
|
||||
import { SparklesIcon } from './icons/SparklesIcon';
|
||||
import { Snippet } from './Snippet';
|
||||
import type { InternalDocSearchHit, StoredDocSearchHit } from './types';
|
||||
import { sanitizeUserInput } from './utils/sanitize';
|
||||
import { decodeHtmlEntities, getHitItemBreadcrumbs } from './utils';
|
||||
|
||||
export type ResultsTranslations = Partial<{
|
||||
askAiPlaceholder: string;
|
||||
|
|
@ -34,12 +35,7 @@ export function Results<TItem extends StoredDocSearchHit>(props: ResultsProps<TI
|
|||
return null;
|
||||
}
|
||||
|
||||
return props.title
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
return decodeHtmlEntities(props.title);
|
||||
}, [props.title]);
|
||||
|
||||
if (!props.collection || props.collection.items.length === 0) {
|
||||
|
|
@ -107,8 +103,8 @@ function Result<TItem extends StoredDocSearchHit>({
|
|||
}: ResultProps<TItem>): JSX.Element {
|
||||
const Hit = hitComponent!;
|
||||
const { recentConversationTimestampFallback = 'A while ago' } = translations;
|
||||
const storedDate = item.type === 'askAI' && item.hierarchy.lvl2 ? new Date(item.hierarchy.lvl2) : null;
|
||||
const relativeDate = useRelativeFormattedDate(storedDate);
|
||||
const titleAttribute = item.type === 'content' ? 'content' : `hierarchy.${item.type}`;
|
||||
const breadcrumbs = getHitItemBreadcrumbs(item);
|
||||
|
||||
return (
|
||||
<li
|
||||
|
|
@ -130,37 +126,18 @@ function Result<TItem extends StoredDocSearchHit>({
|
|||
<div className="DocSearch-Hit-Container">
|
||||
{renderIcon({ item, index })}
|
||||
|
||||
{item.type === 'askAI' && (
|
||||
<div className="DocSearch-Hit-content-wrapper">
|
||||
<span className="DocSearch-Hit-title">{sanitizeUserInput(item.hierarchy.lvl1 || '')}</span>
|
||||
<span className="DocSearch-Hit-path">{relativeDate || recentConversationTimestampFallback}</span>
|
||||
</div>
|
||||
{/* lvl0 is special where there wouldn't be any "parent" to use for breadcrumbs */}
|
||||
{item.type === 'lvl0' && (
|
||||
<HitContent
|
||||
title={<Snippet hit={item} attribute="hierarchy.lvl0" />}
|
||||
subText={<Snippet hit={item} attribute="content" />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{item.hierarchy[item.type] && item.type === 'lvl1' && (
|
||||
<div className="DocSearch-Hit-content-wrapper">
|
||||
<Snippet className="DocSearch-Hit-title" hit={item} attribute="hierarchy.lvl1" />
|
||||
{item.hierarchy.lvl0 && <Snippet className="DocSearch-Hit-path" hit={item} attribute="hierarchy.lvl0" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.hierarchy[item.type] &&
|
||||
(item.type === 'lvl2' ||
|
||||
item.type === 'lvl3' ||
|
||||
item.type === 'lvl4' ||
|
||||
item.type === 'lvl5' ||
|
||||
item.type === 'lvl6') && (
|
||||
<div className="DocSearch-Hit-content-wrapper">
|
||||
<Snippet className="DocSearch-Hit-title" hit={item} attribute={`hierarchy.${item.type}`} />
|
||||
<Snippet className="DocSearch-Hit-path" hit={item} attribute="hierarchy.lvl1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.type === 'content' && (
|
||||
<div className="DocSearch-Hit-content-wrapper">
|
||||
<Snippet className="DocSearch-Hit-title" hit={item} attribute="content" />
|
||||
<Snippet className="DocSearch-Hit-path" hit={item} attribute="hierarchy.lvl1" />
|
||||
</div>
|
||||
{item.type === 'askAI' ? (
|
||||
<AskAIResultContent item={item} relativeDateFallbackText={recentConversationTimestampFallback} />
|
||||
) : (
|
||||
<HitContent title={<Snippet hit={item} attribute={titleAttribute} />} subText={breadcrumbs} />
|
||||
)}
|
||||
|
||||
{renderAction({ item })}
|
||||
|
|
@ -170,6 +147,26 @@ function Result<TItem extends StoredDocSearchHit>({
|
|||
);
|
||||
}
|
||||
|
||||
interface AskAIResultContentProps<TItem extends StoredDocSearchHit> {
|
||||
item: TItem;
|
||||
relativeDateFallbackText: string;
|
||||
}
|
||||
|
||||
function AskAIResultContent<TItem extends StoredDocSearchHit>({
|
||||
item,
|
||||
relativeDateFallbackText,
|
||||
}: AskAIResultContentProps<TItem>) {
|
||||
const storedDate = item.hierarchy.lvl2 ? new Date(item.hierarchy.lvl2) : null;
|
||||
const relativeDate = useRelativeFormattedDate(storedDate);
|
||||
|
||||
return (
|
||||
<HitContent
|
||||
title={decodeHtmlEntities(item.hierarchy.lvl1 || '')}
|
||||
subText={relativeDate || relativeDateFallbackText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface AskAiButtonProps<TItem extends BaseItem> extends ResultsProps<TItem> {
|
||||
item: TItem;
|
||||
translations?: ResultsTranslations;
|
||||
|
|
|
|||
16
packages/docsearch-react/src/components/ui/HitContent.tsx
Normal file
16
packages/docsearch-react/src/components/ui/HitContent.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { JSX, ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
interface HitContentProps {
|
||||
title: ReactNode;
|
||||
subText: ReactNode;
|
||||
}
|
||||
|
||||
export function HitContent({ title, subText }: HitContentProps): JSX.Element {
|
||||
return (
|
||||
<div className="DocSearch-Hit-content-wrapper">
|
||||
<span className="DocSearch-Hit-title">{title}</span>
|
||||
<span className="DocSearch-Hit-path">{subText}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { decodeHtmlEntities } from '../decodeHtmlEntities';
|
||||
|
||||
describe('decodeHtmlEntities', () => {
|
||||
it('returns strings without entities unchanged', () => {
|
||||
expect(decodeHtmlEntities('plain text')).toBe('plain text');
|
||||
});
|
||||
|
||||
it('decodes supported HTML entities', () => {
|
||||
expect(decodeHtmlEntities('<div class="foo">')).toBe('<div class="foo">');
|
||||
expect(decodeHtmlEntities('it's a test')).toBe("it's a test");
|
||||
expect(decodeHtmlEntities('Search & Discovery')).toBe('Search & Discovery');
|
||||
});
|
||||
|
||||
it('does not double-decode escaped entities', () => {
|
||||
expect(decodeHtmlEntities('&lt;')).toBe('<');
|
||||
expect(decodeHtmlEntities('&amp;')).toBe('&');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import type { StoredDocSearchHit } from '../../types';
|
||||
import { getHitItemBreadcrumbs } from '../getHitItemBreadcrumbs';
|
||||
|
||||
function createHit(overrides: Partial<StoredDocSearchHit> = {}): StoredDocSearchHit {
|
||||
return {
|
||||
objectID: '1',
|
||||
content: null,
|
||||
url: 'https://example.com/docs#anchor',
|
||||
url_without_anchor: 'https://example.com/docs',
|
||||
type: 'lvl1',
|
||||
anchor: 'anchor',
|
||||
hierarchy: {
|
||||
lvl0: 'Documentation',
|
||||
lvl1: 'Getting started',
|
||||
lvl2: null,
|
||||
lvl3: null,
|
||||
lvl4: null,
|
||||
lvl5: null,
|
||||
lvl6: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('getHitItemBreadcrumbs', () => {
|
||||
it('returns the levels above the hit own level for lvlX hits', () => {
|
||||
const item = createHit({
|
||||
type: 'lvl2',
|
||||
hierarchy: {
|
||||
lvl0: 'Documentation',
|
||||
lvl1: 'Getting started',
|
||||
lvl2: 'Installation',
|
||||
lvl3: null,
|
||||
lvl4: null,
|
||||
lvl5: null,
|
||||
lvl6: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getHitItemBreadcrumbs(item)).toBe('Documentation > Getting started');
|
||||
});
|
||||
|
||||
it('returns an empty string for lvl0 hits', () => {
|
||||
const item = createHit({ type: 'lvl0' });
|
||||
|
||||
expect(getHitItemBreadcrumbs(item)).toBe('');
|
||||
});
|
||||
|
||||
it('returns all non-null levels for content hits', () => {
|
||||
const item = createHit({
|
||||
type: 'content',
|
||||
content: 'Some matching content',
|
||||
hierarchy: {
|
||||
lvl0: 'Documentation',
|
||||
lvl1: 'Getting started',
|
||||
lvl2: 'Installation',
|
||||
lvl3: null,
|
||||
lvl4: null,
|
||||
lvl5: null,
|
||||
lvl6: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getHitItemBreadcrumbs(item)).toBe('Documentation > Getting started > Installation');
|
||||
});
|
||||
|
||||
it('includes the immediate parent heading for content hits under lvl1', () => {
|
||||
const item = createHit({
|
||||
type: 'content',
|
||||
content: 'Some matching content',
|
||||
});
|
||||
|
||||
expect(getHitItemBreadcrumbs(item)).toBe('Documentation > Getting started');
|
||||
});
|
||||
|
||||
it('skips null levels in the middle of the hierarchy', () => {
|
||||
const item = createHit({
|
||||
type: 'lvl3',
|
||||
hierarchy: {
|
||||
lvl0: 'Documentation',
|
||||
lvl1: 'Getting started',
|
||||
lvl2: null,
|
||||
lvl3: 'Requirements',
|
||||
lvl4: null,
|
||||
lvl5: null,
|
||||
lvl6: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getHitItemBreadcrumbs(item)).toBe('Documentation > Getting started');
|
||||
});
|
||||
|
||||
it('decodes HTML entities in hierarchy values', () => {
|
||||
const item = createHit({
|
||||
type: 'lvl2',
|
||||
hierarchy: {
|
||||
lvl0: 'Search & Discovery',
|
||||
lvl1: '<DocSearch />',
|
||||
lvl2: 'Installation',
|
||||
lvl3: null,
|
||||
lvl4: null,
|
||||
lvl5: null,
|
||||
lvl6: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getHitItemBreadcrumbs(item)).toBe('Search & Discovery > <DocSearch />');
|
||||
});
|
||||
});
|
||||
8
packages/docsearch-react/src/utils/decodeHtmlEntities.ts
Normal file
8
packages/docsearch-react/src/utils/decodeHtmlEntities.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export function decodeHtmlEntities(text: string): string {
|
||||
return text
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
13
packages/docsearch-react/src/utils/getHitItemBreadcrumbs.ts
Normal file
13
packages/docsearch-react/src/utils/getHitItemBreadcrumbs.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { StoredDocSearchHit } from '../types';
|
||||
|
||||
import { decodeHtmlEntities } from './decodeHtmlEntities';
|
||||
|
||||
const LEVELS = ['lvl0', 'lvl1', 'lvl2', 'lvl3', 'lvl4', 'lvl5', 'lvl6'] as const;
|
||||
|
||||
export function getHitItemBreadcrumbs<TItem extends StoredDocSearchHit>(item: TItem): string {
|
||||
const currentIndex = item.type === 'content' || item.type === 'askAI' ? LEVELS.length : LEVELS.indexOf(item.type);
|
||||
return LEVELS.slice(0, currentIndex)
|
||||
.map((lvl) => (item.hierarchy[lvl] ? decodeHtmlEntities(item.hierarchy[lvl]) : null))
|
||||
.filter(Boolean)
|
||||
.join(' > ');
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
export * from './collections';
|
||||
export * from './decodeHtmlEntities';
|
||||
export * from './getHitItemBreadcrumbs';
|
||||
export * from './groupBy';
|
||||
export * from './identity';
|
||||
export * from './isModifierEvent';
|
||||
|
|
|
|||
Loading…
Reference in a new issue