1
0
Fork 0

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:
Paul Jankowski 2026-06-11 13:24:37 -04:00 committed by GitHub
parent 0da291bb95
commit 2b529940e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 221 additions and 38 deletions

View file

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

View file

@ -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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#039;/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;

View 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>
);
}

View file

@ -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('&lt;div class=&quot;foo&quot;&gt;')).toBe('<div class="foo">');
expect(decodeHtmlEntities('it&#039;s a test')).toBe("it's a test");
expect(decodeHtmlEntities('Search &amp; Discovery')).toBe('Search & Discovery');
});
it('does not double-decode escaped entities', () => {
expect(decodeHtmlEntities('&amp;lt;')).toBe('&lt;');
expect(decodeHtmlEntities('&amp;amp;')).toBe('&amp;');
});
});

View file

@ -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 &amp; Discovery',
lvl1: '&lt;DocSearch /&gt;',
lvl2: 'Installation',
lvl3: null,
lvl4: null,
lvl5: null,
lvl6: null,
},
});
expect(getHitItemBreadcrumbs(item)).toBe('Search & Discovery > <DocSearch />');
});
});

View file

@ -0,0 +1,8 @@
export function decodeHtmlEntities(text: string): string {
return text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'")
.replace(/&amp;/g, '&');
}

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

View file

@ -1,4 +1,6 @@
export * from './collections';
export * from './decodeHtmlEntities';
export * from './getHitItemBreadcrumbs';
export * from './groupBy';
export * from './identity';
export * from './isModifierEvent';