From 2b529940e26b70803703b73e1b43c3efcef24f41 Mon Sep 17 00:00:00 2001 From: Paul Jankowski <8bittitan@gmail.com> Date: Thu, 11 Jun 2026 13:24:37 -0400 Subject: [PATCH] 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 --- packages/docsearch-css/src/modal.css | 16 +++ packages/docsearch-react/src/Results.tsx | 73 ++++++------ .../src/components/ui/HitContent.tsx | 16 +++ .../__tests__/decodeHtmlEntities.test.ts | 20 ++++ .../__tests__/getHitItemBreadcrumbs.test.ts | 111 ++++++++++++++++++ .../src/utils/decodeHtmlEntities.ts | 8 ++ .../src/utils/getHitItemBreadcrumbs.ts | 13 ++ packages/docsearch-react/src/utils/index.ts | 2 + 8 files changed, 221 insertions(+), 38 deletions(-) create mode 100644 packages/docsearch-react/src/components/ui/HitContent.tsx create mode 100644 packages/docsearch-react/src/utils/__tests__/decodeHtmlEntities.test.ts create mode 100644 packages/docsearch-react/src/utils/__tests__/getHitItemBreadcrumbs.test.ts create mode 100644 packages/docsearch-react/src/utils/decodeHtmlEntities.ts create mode 100644 packages/docsearch-react/src/utils/getHitItemBreadcrumbs.ts diff --git a/packages/docsearch-css/src/modal.css b/packages/docsearch-css/src/modal.css index 392e1476..3055911e 100644 --- a/packages/docsearch-css/src/modal.css +++ b/packages/docsearch-css/src/modal.css @@ -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; diff --git a/packages/docsearch-react/src/Results.tsx b/packages/docsearch-react/src/Results.tsx index fe7cf93f..4641f64e 100644 --- a/packages/docsearch-react/src/Results.tsx +++ b/packages/docsearch-react/src/Results.tsx @@ -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(props: ResultsProps') - .replace(/"/g, '"') - .replace(/'/g, "'"); + return decodeHtmlEntities(props.title); }, [props.title]); if (!props.collection || props.collection.items.length === 0) { @@ -107,8 +103,8 @@ function Result({ }: ResultProps): 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 (
  • ({
    {renderIcon({ item, index })} - {item.type === 'askAI' && ( -
    - {sanitizeUserInput(item.hierarchy.lvl1 || '')} - {relativeDate || recentConversationTimestampFallback} -
    + {/* lvl0 is special where there wouldn't be any "parent" to use for breadcrumbs */} + {item.type === 'lvl0' && ( + } + subText={} + /> )} - {item.hierarchy[item.type] && item.type === 'lvl1' && ( -
    - - {item.hierarchy.lvl0 && } -
    - )} - - {item.hierarchy[item.type] && - (item.type === 'lvl2' || - item.type === 'lvl3' || - item.type === 'lvl4' || - item.type === 'lvl5' || - item.type === 'lvl6') && ( -
    - - -
    - )} - - {item.type === 'content' && ( -
    - - -
    + {item.type === 'askAI' ? ( + + ) : ( + } subText={breadcrumbs} /> )} {renderAction({ item })} @@ -170,6 +147,26 @@ function Result({ ); } +interface AskAIResultContentProps { + item: TItem; + relativeDateFallbackText: string; +} + +function AskAIResultContent({ + item, + relativeDateFallbackText, +}: AskAIResultContentProps) { + const storedDate = item.hierarchy.lvl2 ? new Date(item.hierarchy.lvl2) : null; + const relativeDate = useRelativeFormattedDate(storedDate); + + return ( + + ); +} + interface AskAiButtonProps extends ResultsProps { item: TItem; translations?: ResultsTranslations; diff --git a/packages/docsearch-react/src/components/ui/HitContent.tsx b/packages/docsearch-react/src/components/ui/HitContent.tsx new file mode 100644 index 00000000..0bbe3dc2 --- /dev/null +++ b/packages/docsearch-react/src/components/ui/HitContent.tsx @@ -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 ( +
    + {title} + {subText} +
    + ); +} diff --git a/packages/docsearch-react/src/utils/__tests__/decodeHtmlEntities.test.ts b/packages/docsearch-react/src/utils/__tests__/decodeHtmlEntities.test.ts new file mode 100644 index 00000000..6ef958ad --- /dev/null +++ b/packages/docsearch-react/src/utils/__tests__/decodeHtmlEntities.test.ts @@ -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('
    '); + 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('&'); + }); +}); diff --git a/packages/docsearch-react/src/utils/__tests__/getHitItemBreadcrumbs.test.ts b/packages/docsearch-react/src/utils/__tests__/getHitItemBreadcrumbs.test.ts new file mode 100644 index 00000000..436fb584 --- /dev/null +++ b/packages/docsearch-react/src/utils/__tests__/getHitItemBreadcrumbs.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest'; + +import type { StoredDocSearchHit } from '../../types'; +import { getHitItemBreadcrumbs } from '../getHitItemBreadcrumbs'; + +function createHit(overrides: Partial = {}): 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 > '); + }); +}); diff --git a/packages/docsearch-react/src/utils/decodeHtmlEntities.ts b/packages/docsearch-react/src/utils/decodeHtmlEntities.ts new file mode 100644 index 00000000..9b3e4536 --- /dev/null +++ b/packages/docsearch-react/src/utils/decodeHtmlEntities.ts @@ -0,0 +1,8 @@ +export function decodeHtmlEntities(text: string): string { + return text + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); +} diff --git a/packages/docsearch-react/src/utils/getHitItemBreadcrumbs.ts b/packages/docsearch-react/src/utils/getHitItemBreadcrumbs.ts new file mode 100644 index 00000000..a8da2e61 --- /dev/null +++ b/packages/docsearch-react/src/utils/getHitItemBreadcrumbs.ts @@ -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(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(' > '); +} diff --git a/packages/docsearch-react/src/utils/index.ts b/packages/docsearch-react/src/utils/index.ts index acc6081e..369dde9a 100644 --- a/packages/docsearch-react/src/utils/index.ts +++ b/packages/docsearch-react/src/utils/index.ts @@ -1,4 +1,6 @@ export * from './collections'; +export * from './decodeHtmlEntities'; +export * from './getHitItemBreadcrumbs'; export * from './groupBy'; export * from './identity'; export * from './isModifierEvent';