1
0
Fork 0

feat: Facet filters for search (#2899)

* feat(v5): Initial facet filters work

* Perf updates, dark theme, facet chips, a11y improvements

* fix: bump css bundle size limit

* Dedupe facet filters, refetch facets on searchParameters changes

* Add chevron flourish
This commit is contained in:
Paul Jankowski 2026-06-16 16:38:18 -04:00 committed by GitHub
parent 96eda2362e
commit ef2bec0e20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1621 additions and 90 deletions

View file

@ -101,12 +101,11 @@ import type { DocSearchHit } from './types';
- Use `type` imports for type-only imports: `import type { Foo } from './types'`
- Prefer interfaces for object shapes, types for unions/primitives
- Explicit return types on exported functions
- Use `declare type` for exported type declarations
- Avoid `any`; use `unknown` when type is truly unknown
```typescript
// Good
export declare type DocSearchHit = {
export type DocSearchHit = {
objectID: string;
content: string | null;
};

View file

@ -2,7 +2,7 @@
"files": [
{
"path": "packages/docsearch-css/dist/style.css",
"maxSize": "7.10 kB"
"maxSize": "7.80 kB"
},
{
"path": "packages/docsearch-react/dist/umd/index.js",

View file

@ -14,6 +14,11 @@ export default function BasicAskAI({ theme }: { theme: DemoTheme }): JSX.Element
assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef',
suggestedQuestions: true,
}}
facets={[
{ key: 'language', label: 'Language' },
{ key: 'version', label: 'Version' },
{ key: 'type', label: 'Content type' },
]}
insights={true}
translations={{ button: { buttonText: 'Search with Ask AI' } }}
theme={theme}

View file

@ -13,6 +13,11 @@ export default function Basic({ theme }: { theme: DemoTheme }): JSX.Element {
translations={{ button: { buttonText: 'Keyword search' } }}
insights={true}
theme={theme}
facets={[
{ key: 'language', label: 'Language' },
{ key: 'version', label: 'Version' },
{ key: 'type', label: 'Content type' },
]}
/>
);
}

View file

@ -2,6 +2,7 @@
:root {
--docsearch-primary-color: rgb(0, 61, 255);
--docsearch-primary-dark-color: rgba(2, 46, 185, 1);
--docsearch-soft-primary-color: rgba(0, 61, 255, 0.1);
--docsearch-subtle-color: rgb(214, 214, 231);
--docsearch-text-color: rgba(35, 38, 59, 1);
@ -78,14 +79,24 @@
--docsearch-dropdown-menu-background: var(--docsearch-hit-background);
--docsearch-dropdown-menu-item-hover-background: var(--docsearch-background-color);
/* Chip */
--docsearch-chip-text-color: var(--docsearch-primary-dark-color);
--docsearch-chip-background: rgb(187, 209, 255);
/* Menu */
--docsearch-menu-trigger-active-background: var(--docsearch-soft-primary-color);
--docsearch-menu-trigger-active-text-color: var(--docsearch-primary-dark-color);
/* Popover */
--docsearch-popover-background: #fff;
--docsearch-popover-shadow: 0px 0px 0px 1px rgba(33, 36, 61, 0.05), 0px 4px 8px -2px rgba(33, 36, 61, 0.25);
--docsearch-popover-arrow-color: rgb(230, 230, 240);
}
/* Darkmode */
html[data-theme='dark'] {
--docsearch-primary-dark-color: rgba(154, 200, 255, 1);
--docsearch-text-color: rgba(196, 199, 220, 1);
--docsearch-secondary-text-color: rgba(182, 183, 213, 1);
--docsearch-subtle-color: rgba(33, 33, 57, 1);
@ -125,7 +136,16 @@ html[data-theme='dark'] {
--docsearch-search-button-background: var(--docsearch-modal-background);
--docsearch-search-button-text-color: var(--docsearch-text-color);
/* Popover */
/* Chip */
--docsearch-chip-text-color: var(--docsearch-text-color);
--docsearch-chip-background: var(--docsearch-hit-focus-background);
/* Menu */
--docsearch-menu-trigger-active-background: var(--docsearch-hit-focus-background);
--docsearch-menu-trigger-active-text-color: var(--docsearch-text-color);
/* Popover */
--docsearch-popover-background: var(--docsearch-hit-focus-background);
--docsearch-popover-shadow: 0px 0px 0px 1px rgba(255, 255, 255, 0.08), 0px 4px 8px -2px rgba(0, 0, 0, 0.55);
--docsearch-popover-arrow-color: var(--docsearch-hit-focus-background);
}

View file

@ -56,7 +56,8 @@
.DocSearch-Popover-Positioner {
width: var(--positioner-width);
height: var(--positioner-height);
max-width: var(--available-width)
max-width: var(--available-width);
isolation: isolate;
}
.DocSearch-Popover-Popup {
@ -66,7 +67,7 @@
position: relative;
background-color: var(--docsearch-popover-background);
z-index: 999;
box-shadow: 0px 0px 0px 1px rgba(33, 36, 61, 0.05), 0px 4px 8px -2px rgba(33, 36, 61, 0.25);
box-shadow: var(--docsearch-popover-shadow);
border-radius: var(--docsearch-border-radius);
padding-inline: 1rem;
padding-block: 1rem;
@ -154,6 +155,201 @@
width: 1rem;
}
/* DocSearch Menu */
.DocSearch-Menu {
position: relative;
}
.DocSearch-Menu-content {
display: none;
position: absolute;
inset-inline-end: 0;
padding: 8px 0;
flex-direction: column;
border-radius: var(--docsearch-border-radius);
background-color: var(--docsearch-dropdown-menu-background);
box-shadow:
0px 0px 0px 1px #21243d0d,
0px 8px 16px -4px #21243d40;
min-width: 195px;
inset-block-start: calc(100% + 12px);
z-index: 422;
}
.DocSearch-Menu-content.open {
display: flex;
}
.DocSearch-Menu-item {
padding: 10px 16px;
display: flex;
align-items: center;
color: var(--docsearch-text-color);
cursor: pointer;
font-size: 14px;
gap: 8px;
white-space: nowrap;
border: 0;
background-color: transparent;
}
.DocSearch-Menu-item:hover {
background-color: var(--docsearch-dropdown-menu-item-hover-background);
}
.DocSearch-Menu-Trigger {
align-items: center;
appearance: none;
background: transparent;
border: 0;
border-radius: 0.25rem;
color: var(--docsearch-text-color);
cursor: pointer;
display: inline-flex;
font: inherit;
font-size: 0.875em;
font-weight: 400;
gap: 0.5em;
line-height: 1.43em;
padding-block: 0.285em;
padding-inline: 0.571em;
&:hover,
&[data-popup-open],
&[data-has-selection="true"] {
background: var(--docsearch-menu-trigger-active-background);
color: var(--docsearch-menu-trigger-active-text-color);
}
&:focus-visible {
outline: 2px solid var(--docsearch-primary-color);
}
& svg {
stroke-width: var(--docsearch-icon-stroke-width);
inline-size: 1.5rem;
block-size: 1.5rem;
flex-shrink: 0;
transform: rotate(0deg);
transition: transform 0.15s ease-in-out;
}
&[data-popup-open] svg {
transform: rotate(-180deg);
}
@media (prefers-reduced-motion: reduce) {
& svg {
transition: none;
}
}
}
.DocSearch-Menu-Positioner {
isolation: isolate;
z-index: 999;
}
.DocSearch-Menu-Popup {
background-color: var(--docsearch-popover-background);
border-radius: 0.25rem;
box-shadow: var(--docsearch-popover-shadow);
max-height: 20rem;
min-width: 12.5rem;
outline: none;
overflow-y: auto;
padding-block: 0.5rem;
transform-origin: var(--transform-origin);
}
.DocSearch-Menu-Item {
display: flex;
align-items: center;
color: var(--docsearch-text-color);
cursor: pointer;
font-size: 0.875em;
line-height: 1.43em;
outline: none;
padding-block: 0.714em;
padding-inline: 1.14em;
user-select: none;
gap: 0.25rem;
min-height: 3rem;
@media screen and (min-width: 768px) {
min-height: 0;
}
&:hover,
&[data-highlighted] {
background: var(--docsearch-dropdown-menu-item-hover-background);
}
& svg {
flex-shrink: 0;
}
}
.DocSearch-Menu-RadioItem[data-checked] {
color: var(--docsearch-highlight-color);
}
.DocSearch-Menu-RadioItem-Indicator {
--indicator-size: 1rem;
pointer-events: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--indicator-size);
& span {
display: inline-flex;
align-items: center;
justify-content: center;
}
& svg {
stroke-width: var(--docsearch-icon-stroke-width);
height: var(--indicator-size);
width: var(--indicator-size);
}
}
/* Chip */
.DocSearch-Chip {
display: inline-flex;
align-items: center;
gap: 0.29em;
font-size: 0.875em;
color: var(--docsearch-chip-text-color);
background: var(--docsearch-chip-background);
padding-inline: 0.57em;
border-radius: 100vw;
height: 2.14em;
&:focus-within,
&:has(:focus-visible) {
outline: 2px solid var(--docsearch-primary-color);
}
}
.DocSearch-Chip-Dismiss {
all: unset;
color: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
height: 1em;
width: 1em;
cursor: pointer;
& svg {
stroke-width: var(--docsearch-icon-stroke-width);
}
}
/* Modal Searchbox */
.DocSearch-SearchBar {
@ -164,6 +360,63 @@
color: var(--docsearch-highlight-color);
}
/* Facet bar */
.DocSearch-FacetBar {
align-items: center;
background: var(--docsearch-modal-background);
display: flex;
gap: 0.57em;
overflow-x: auto;
padding-block: 0.57em;
padding-inline: 0.857em;
scrollbar-width: none;
}
.DocSearch-FacetBar::-webkit-scrollbar {
display: none;
}
/* Facet selection bar */
.DocSearch-FacetSelectionBar {
display: flex;
align-items: center;
flex-wrap: wrap;
padding-block: 0.57em;
padding-inline: 0.857em;
gap: 0.57em;
border-block: 1px solid var(--docsearch-subtle-color);
}
.DocSearch-FacetSelectionBar::-webkit-scrollbar {
display: none;
}
.DocSearch-FacetSelectionBar-Action {
all: unset;
color: var(--docsearch-secondary-text-color);
font-size: 0.875em;
display: inline-flex;
align-items: center;
padding-inline: 0.57em;
border-radius: 100vw;
height: 2.14em;
cursor: pointer;
&:hover,
&:focus {
background-color: var(--docsearch-background-color);
color: var(--docsearch-text-color);
}
&:focus-visible {
outline: 2px solid var(--docsearch-primary-color);
}
}
/* DocSearch Form */
.DocSearch-Form {
align-items: center;
background: var(--docsearch-searchbox-focus-background);
@ -1974,44 +2227,3 @@ html[data-theme='dark'] .DocSearch-AskAiScreen-MessageContent-Thinking {
color: var(--docsearch-text-color);
cursor: pointer;
}
.DocSearch-Menu {
position: relative;
}
.DocSearch-Menu-content {
display: none;
position: absolute;
inset-inline-end: 0;
padding: 8px 0;
flex-direction: column;
border-radius: var(--docsearch-border-radius);
background-color: var(--docsearch-dropdown-menu-background);
box-shadow:
0px 0px 0px 1px #21243d0d,
0px 8px 16px -4px #21243d40;
min-width: 195px;
inset-block-start: calc(100% + 12px);
z-index: 422;
}
.DocSearch-Menu-content.open {
display: flex;
}
.DocSearch-Menu-item {
padding: 10px 16px;
display: flex;
align-items: center;
color: var(--docsearch-text-color);
cursor: pointer;
font-size: 14px;
gap: 8px;
white-space: nowrap;
border: 0;
background-color: transparent;
}
.DocSearch-Menu-item:hover {
background-color: var(--docsearch-dropdown-menu-item-hover-background);
}

View file

@ -113,7 +113,7 @@ export const AskAiScreenState = React.memo(
return (
<>
<ResultsScreen {...props} translations={translations?.resultsScreen} />
{props.canHandleAskAi && props.state.collections.length === 1 && (
{props.canHandleAskAi && !props.hasCollections && (
// if there's one collection it is the ask ai action, show the no results screen
<NoResultsScreen {...props} translations={translations?.noResultsScreen} />
)}

View file

@ -30,6 +30,11 @@ export interface DocSearchIndex {
searchParameters?: SearchParamsObject;
}
export interface DocSearchFacet {
key: string;
label?: string;
}
export interface DocSearchProps {
/**
* Algolia application id used by the search client.
@ -51,6 +56,13 @@ export interface DocSearchProps {
* @see {@link https://docsearch.algolia.com/docs/api#indices}
*/
indices?: Array<DocSearchIndex | string>;
/**
* Facets to display as keyword-search filter controls.
* Values are read dynamically from the configured Algolia indices.
*
* @default []
*/
facets?: DocSearchFacet[];
/**
* Theme overrides applied to the modal and related components.
*/

View file

@ -7,11 +7,14 @@ import type { AskAiScreenStateTranslations } from './AskAiScreenState';
import { AskAiScreenState } from './AskAiScreenState';
import type { AskAiSearchBoxTranslations } from './components/AskAiSearchBox';
import { AskAiSearchBox } from './components/AskAiSearchBox';
import type { FacetBarTranslations } from './components/FacetBar';
import { FacetBar } from './components/FacetBar';
import { ModalShell } from './components/ui/ModalShell';
import type { DocSearchAIProps } from './DocSearchAI';
import type { FooterTranslations } from './Footer';
import { Footer } from './Footer';
import { Hit } from './Hit';
import { useDocSearchFacets } from './hooks/useDocSearchFacets';
import { useSendItemClickEvent } from './hooks/useDocSearchInsights';
import { useInitialModalQuery } from './hooks/useInitialModalQuery';
import { useModalEnvironment } from './hooks/useModalEnvironment';
@ -31,7 +34,7 @@ import { type AskAiState } from './types/AskiAi';
import { useAskAi } from './useAskAi';
import { useSearchClient } from './useSearchClient';
import { useSuggestedQuestions } from './useSuggestedQuestions';
import { identity, isModifierEvent, noop, scrollTo as scrollToUtils } from './utils';
import { identity, isModifierEvent, noop, scrollTo as scrollToUtils, SOURCE_IDS } from './utils';
import { buildDummyAskAiHit, isThreadDepthError, EMPTY_TOOLS } from './utils/ai';
import { buildAskAiActionSources, buildRecentConversationSources } from './utils/createAskAiSources';
import { buildNoQuerySources, buildQuerySources, type BuildQuerySourcesState } from './utils/createDocSearchSources';
@ -42,6 +45,7 @@ export type DocSearchAskAiModalTranslations = AskAiScreenStateTranslations &
searchBox: AskAiSearchBoxTranslations;
newConversation: NewConversationTranslations;
footer: FooterTranslations;
facets: FacetBarTranslations;
}>;
export type DocSearchAskAiModalProps = DocSearchAIProps & {
@ -79,11 +83,17 @@ export function DocSearchAskAiModal({
indices = [],
indexName,
searchParameters,
facets,
isHybridModeSupported = false,
tools = EMPTY_TOOLS,
...props
}: DocSearchAskAiModalProps): JSX.Element {
const { footer: footerTranslations, searchBox: searchBoxTranslations, ...screenStateTranslations } = translations;
const {
footer: footerTranslations,
searchBox: searchBoxTranslations,
facets: facetBarTranslations,
...screenStateTranslations
} = translations;
const [state, setState] = React.useState<DocSearchState<InternalDocSearchHit>>({
query: '',
collections: [],
@ -123,13 +133,37 @@ export function DocSearchAskAiModal({
});
const memoryEnabled = props.memory?.enabled ?? false;
const indexes = normalizeDocSearchIndexes({
indexName,
indices,
searchParameters,
});
const indexes = React.useMemo(
() =>
normalizeDocSearchIndexes({
indexName,
indices,
searchParameters,
}),
[indexName, indices, searchParameters],
);
const defaultIndexName = indexes[0].name;
const autocompleteRef =
React.useRef<
ReturnType<
typeof createAutocomplete<
InternalDocSearchHit,
React.FormEvent<HTMLFormElement>,
React.MouseEvent,
React.KeyboardEvent
>
>
>(undefined);
const { visibleFacets, facetSelections, facetSelectionsRef, handleFacetSelectionChange, clearFacetSelections } =
useDocSearchFacets({
facets,
indexes,
searchClient,
onSelectionsChange: () => autocompleteRef.current?.refresh(),
});
const { favoriteSearches, recentSearches } = useStoredDocSearches({
defaultIndexName,
recentSearchesLimit,
@ -185,18 +219,6 @@ export function DocSearchAskAiModal({
});
const sendItemClickEvent = useSendItemClickEvent(state);
const autocompleteRef =
React.useRef<
ReturnType<
typeof createAutocomplete<
InternalDocSearchHit,
React.FormEvent<HTMLFormElement>,
React.MouseEvent,
React.KeyboardEvent
>
>
>(undefined);
const handleSelectAskAiQuestion = React.useCallback(
(toggle: boolean, query: string, suggestedQuestion: SuggestedQuestionHit | undefined = undefined) => {
if (toggle) {
@ -334,6 +356,7 @@ export function DocSearchAskAiModal({
transformItems,
saveRecentSearch,
onClose,
facetSelections: facetSelectionsRef,
});
const askAiSource = canHandleAskAi ? buildAskAiActionSources({ query, handleSelectAskAiQuestion }) : [];
@ -404,7 +427,9 @@ export function DocSearchAskAiModal({
// hide the dropdown on idle and no collections
let showDocsearchDropdown = true;
const hasCollections = state.collections.some((collection) => collection.items.length > 0);
const hasCollections = state.collections.some(
(collection) => collection.source.sourceId !== SOURCE_IDS.askAI && collection.items.length > 0,
);
if (state.status === 'idle' && hasCollections === false && state.query.length === 0 && !isAskAiActive) {
showDocsearchDropdown = false;
}
@ -443,6 +468,17 @@ export function DocSearchAskAiModal({
onViewConversationHistory={handleViewConversationHistory}
/>
}
filterBar={
!isAskAiActive && state.query !== '' ? (
<FacetBar
facets={visibleFacets}
selections={facetSelections}
translations={facetBarTranslations}
clearSelections={clearFacetSelections}
onSelectionChange={handleFacetSelectionChange}
/>
) : null
}
screenState={
<AskAiScreenState
{...autocomplete}

View file

@ -1,6 +1,8 @@
import { createAutocomplete } from '@algolia/autocomplete-core';
import React, { type JSX } from 'react';
import type { FacetBarTranslations } from './components/FacetBar';
import { FacetBar } from './components/FacetBar';
import type { KeywordSearchBoxTranslations } from './components/KeywordSearchBox';
import { KeywordSearchBox } from './components/KeywordSearchBox';
import { ModalShell } from './components/ui/ModalShell';
@ -8,6 +10,7 @@ import type { DocSearchProps } from './DocSearch';
import type { FooterTranslations } from './Footer';
import { Footer } from './Footer';
import { Hit } from './Hit';
import { useDocSearchFacets } from './hooks/useDocSearchFacets';
import { useSendItemClickEvent } from './hooks/useDocSearchInsights';
import { useInitialModalQuery } from './hooks/useInitialModalQuery';
import { useModalEnvironment } from './hooks/useModalEnvironment';
@ -26,6 +29,7 @@ import { normalizeDocSearchIndexes } from './utils/normalizeDocSearchIndexes';
export type ModalTranslations = Partial<{
searchBox: KeywordSearchBoxTranslations;
footer: FooterTranslations;
facets: FacetBarTranslations;
}> &
ScreenStateTranslations;
@ -57,9 +61,15 @@ export function DocSearchModal({
indices = [],
indexName,
searchParameters,
facets,
...props
}: DocSearchModalProps): JSX.Element {
const { footer: footerTranslations, searchBox: searchBoxTranslations, ...screenStateTranslations } = translations;
const {
footer: footerTranslations,
searchBox: searchBoxTranslations,
facets: facetBarTranslations,
...screenStateTranslations
} = translations;
const [state, setState] = React.useState<DocSearchState<InternalDocSearchHit>>({
query: '',
collections: [],
@ -77,25 +87,17 @@ export function DocSearchModal({
const searchClient = useSearchClient(appId, apiKey, transformSearchClient);
const indexes = normalizeDocSearchIndexes({
indexName,
indices,
searchParameters,
});
const indexes = React.useMemo(
() =>
normalizeDocSearchIndexes({
indexName,
indices,
searchParameters,
}),
[indexName, indices, searchParameters],
);
const defaultIndexName = indexes[0].name;
const { favoriteSearches, recentSearches } = useStoredDocSearches({
defaultIndexName,
recentSearchesLimit,
recentSearchesWithFavoritesLimit,
});
const saveRecentSearch = useSaveRecentSearch({
favoriteSearches,
recentSearches,
disableUserPersonalization,
});
const sendItemClickEvent = useSendItemClickEvent(state);
const autocompleteRef =
React.useRef<
ReturnType<
@ -108,6 +110,26 @@ export function DocSearchModal({
>
>(undefined);
const { visibleFacets, facetSelections, facetSelectionsRef, handleFacetSelectionChange, clearFacetSelections } =
useDocSearchFacets({
facets,
indexes,
searchClient,
onSelectionsChange: () => autocompleteRef.current?.refresh(),
});
const { favoriteSearches, recentSearches } = useStoredDocSearches({
defaultIndexName,
recentSearchesLimit,
recentSearchesWithFavoritesLimit,
});
const saveRecentSearch = useSaveRecentSearch({
favoriteSearches,
recentSearches,
disableUserPersonalization,
});
const sendItemClickEvent = useSendItemClickEvent(state);
if (!autocompleteRef.current) {
autocompleteRef.current = createAutocomplete({
id: 'docsearch',
@ -155,6 +177,7 @@ export function DocSearchModal({
transformItems,
saveRecentSearch,
onClose,
facetSelections: facetSelectionsRef,
});
return algoliaSourcesPromise;
@ -214,6 +237,17 @@ export function DocSearchModal({
onClose={onClose}
/>
}
filterBar={
state.query !== '' ? (
<FacetBar
facets={visibleFacets}
selections={facetSelections}
translations={facetBarTranslations}
clearSelections={clearFacetSelections}
onSelectionChange={handleFacetSelectionChange}
/>
) : null
}
screenState={
<ScreenState
{...autocomplete}

View file

@ -0,0 +1,112 @@
import { renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { DocSearchFacet, DocSearchIndex } from '../DocSearch';
import { useFacetValues } from '../useFacetValues';
describe('useFacetValues', () => {
const search = vi.fn();
const searchClient = { search } as any;
beforeEach(() => {
vi.clearAllMocks();
search.mockResolvedValue({
results: [
{
facets: {
language: { en: 10, fr: 4 },
version: { 'v1.0': 6 },
},
},
],
});
});
it('fetches facet values once and merges them per facet', async () => {
const facets: DocSearchFacet[] = [{ key: 'language' }, { key: 'version' }];
const indexes: DocSearchIndex[] = [{ name: 'docs' }];
const { result } = renderHook(() => useFacetValues({ facets, indexes, searchClient }));
await waitFor(() => {
expect(result.current).toEqual({
language: ['en', 'fr'],
version: ['v1.0'],
});
});
expect(search).toHaveBeenCalledTimes(1);
});
it('does not re-fetch when facet/index props are recreated with identical content', async () => {
const { result, rerender } = renderHook(
({ facets, indexes }: { facets: DocSearchFacet[]; indexes: DocSearchIndex[] }) =>
useFacetValues({ facets, indexes, searchClient }),
{
initialProps: {
facets: [{ key: 'language' }] as DocSearchFacet[],
indexes: [{ name: 'docs' }] as DocSearchIndex[],
},
},
);
await waitFor(() => {
expect(result.current.language).toBeDefined();
});
// New array/object identities but identical content (mirrors the
// per-render prop recreation that previously caused an infinite loop).
rerender({ facets: [{ key: 'language' }], indexes: [{ name: 'docs' }] });
rerender({ facets: [{ key: 'language' }], indexes: [{ name: 'docs' }] });
expect(search).toHaveBeenCalledTimes(1);
});
it('re-fetches when searchParameters change', async () => {
const { result, rerender } = renderHook(
({ facets, indexes }: { facets: DocSearchFacet[]; indexes: DocSearchIndex[] }) =>
useFacetValues({ facets, indexes, searchClient }),
{
initialProps: {
facets: [{ key: 'language' }] as DocSearchFacet[],
indexes: [{ name: 'docs', searchParameters: { analytics: true } }] as DocSearchIndex[],
},
},
);
await waitFor(() => {
expect(result.current.language).toBeDefined();
});
rerender({
facets: [{ key: 'language' }],
indexes: [{ name: 'docs', searchParameters: { analytics: true } }],
});
rerender({
facets: [{ key: 'language' }],
indexes: [{ name: 'docs', searchParameters: { analytics: false } }],
});
expect(search).toHaveBeenCalledTimes(2);
});
it('does not search when there are no facets', () => {
const { result } = renderHook(() => useFacetValues({ facets: [], indexes: [{ name: 'docs' }], searchClient }));
expect(result.current).toEqual({});
expect(search).not.toHaveBeenCalled();
});
it('does not search when there are no indexes', () => {
const { result } = renderHook(() =>
useFacetValues({
facets: [{ key: 'language' }],
indexes: [],
searchClient,
}),
);
expect(result.current).toEqual({});
expect(search).not.toHaveBeenCalled();
});
});

View file

@ -1,7 +1,9 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { AIMessage } from '../types/AskiAi';
import { extractLinksFromMessage } from '../utils/ai';
import { createFacetFilters } from '../utils/createDocSearchSources';
import { getFacetLabel, normalizeFacets } from '../utils/facets';
import {
createObjectStorage,
createStorage,
@ -11,6 +13,63 @@ import {
} from '../utils/storage';
describe('utils', () => {
describe('facet filters', () => {
it('normalizes facets and enforces the maximum', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(
normalizeFacets([
{ key: 'language' },
{ key: 'version' },
{ key: 'type' },
{ key: 'framework' },
{ key: 'platform' },
{ key: 'extra' },
{ key: ' ' },
]),
).toEqual([{ key: 'language' }, { key: 'version' }, { key: 'type' }, { key: 'framework' }, { key: 'platform' }]);
warn.mockRestore();
});
it('dedupes facets based on key', () => {
expect(
normalizeFacets([
{ key: 'language' },
{ key: 'version' },
{ key: 'Language' },
{ key: 'type' },
{ key: 'VerSion' },
{ key: 'framework' },
{ key: 'platform' },
{ key: 'extra' },
]),
).toEqual([{ key: 'language' }, { key: 'version' }, { key: 'type' }, { key: 'framework' }, { key: 'platform' }]);
});
it('creates labels from facet keys', () => {
expect(getFacetLabel({ key: 'content_type' })).toBe('Content Type');
expect(getFacetLabel({ key: 'docs.version', label: 'Version' })).toBe('Version');
});
it('returns configured facetFilters when no dynamic facets are selected', () => {
expect(createFacetFilters(['language:en'], {})).toEqual(['language:en']);
});
it('merges configured and dynamic facetFilters', () => {
expect(
createFacetFilters(['docusaurus_tag:default'], {
language: 'en',
version: 'v2',
}),
).toEqual(['docusaurus_tag:default', 'language:en', 'version:v2']);
});
it('ignores empty dynamic facet selections', () => {
expect(createFacetFilters(undefined, { language: '', version: 'v2' })).toEqual(['version:v2']);
});
});
describe('extractLinksFromText', () => {
it('returns an empty array when no links are present', () => {
const message: AIMessage = {

View file

@ -0,0 +1,248 @@
import React, { type JSX } from 'react';
import type { DocSearchFacet } from '../DocSearch';
import { ChevronIcon } from '../icons';
import { capitalize } from '../utils';
import { getFacetLabel } from '../utils/facets';
import { Chip } from './ui/Chip';
import { Menu } from './ui/Menu';
export type FacetBarTranslations = Partial<{
/**
* Label displayed as the default (all) facet option.
*
* @default "All"
*/
defaultValueLabel: string;
/**
* Facet menu trigger aria label when a facet is selected.
*
* @default "selected"
*/
facetMenuTriggerAriaLabel: string;
/**
* Label displayed for clearing all facets action.
*
* @default "Clear all"
*/
clearAllLabel: string;
/**
* Aria label for the list of possible facets.
*
* @default "Search filters"
*/
facetsAriaLabel: string;
/**
* Aria label for the list of selected facets.
*
* @default "Selected search filters"
*/
selectedFacetsAriaLabel: string;
/**
* Aria label indicating to clear a selected facet.
*
* @default "Clear filter:"
*/
clearFacetAriaLabel: string;
}>;
export interface FacetBarFacet extends DocSearchFacet {
values: string[];
}
interface FacetMenuProps {
facet: FacetBarFacet;
selectedValue: string;
defaultValueLabel: string;
menuTriggerSelectedAriaLabel: string;
onSelectionChange: (facet: string, value: string) => void;
registerTrigger: (facetKey: string, el: HTMLButtonElement | null) => void;
}
const FacetMenu = React.memo(function FacetMenu({
facet,
selectedValue,
defaultValueLabel,
onSelectionChange,
registerTrigger,
menuTriggerSelectedAriaLabel,
}: FacetMenuProps): JSX.Element {
const label = getFacetLabel(facet);
const handleValueChange = React.useCallback(
(value: string) => {
onSelectionChange(facet.key, value);
},
[facet.key, onSelectionChange],
);
const triggerRef = React.useCallback(
(el: HTMLButtonElement | null) => {
registerTrigger(facet.key, el);
},
[facet.key, registerTrigger],
);
return (
<Menu>
<Menu.Trigger
ref={triggerRef}
data-has-selection={Boolean(selectedValue)}
aria-label={selectedValue ? `${label}, ${selectedValue} ${menuTriggerSelectedAriaLabel}` : label}
>
<span>{capitalize(label)}</span>
<ChevronIcon />
</Menu.Trigger>
<Menu.Popup>
<Menu.RadioGroup value={selectedValue} onValueChange={handleValueChange}>
<Menu.RadioItem value="" label={`${defaultValueLabel} ${label}`}>
{defaultValueLabel}
</Menu.RadioItem>
{facet.values.map((value) => (
<Menu.RadioItem key={value} value={value} label={value}>
{capitalize(value)}
</Menu.RadioItem>
))}
</Menu.RadioGroup>
</Menu.Popup>
</Menu>
);
});
interface SelectedFacetChipProps {
facetKey: string;
facetLabel: string;
value: string;
dismissAriaLabel: string;
onDismiss: (facetKey: string, event: React.MouseEvent<HTMLButtonElement>) => void;
}
const SelectedFacetChip = React.memo(function SelectedFacetChip({
facetKey,
facetLabel,
value,
dismissAriaLabel,
onDismiss,
}: SelectedFacetChipProps): JSX.Element {
const handleDismissFacet = React.useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
onDismiss(facetKey, e);
},
[facetKey, onDismiss],
);
return (
<Chip>
{capitalize(value)}
<Chip.Dismiss
aria-label={`${dismissAriaLabel} ${capitalize(value)} (${facetLabel})`}
onClick={handleDismissFacet}
/>
</Chip>
);
});
interface FacetBarProps {
facets: FacetBarFacet[];
selections: Record<string, string>;
onSelectionChange: (facet: string, value: string) => void;
clearSelections: () => void;
translations?: FacetBarTranslations;
}
export const FacetBar = React.memo(function FacetBar({
facets,
selections,
onSelectionChange,
clearSelections,
translations = {},
}: FacetBarProps): JSX.Element | null {
const {
defaultValueLabel = 'All',
facetMenuTriggerAriaLabel = 'selected',
clearAllLabel = 'Clear all',
facetsAriaLabel = 'Search filters',
selectedFacetsAriaLabel = 'Selected search filters',
clearFacetAriaLabel = 'Clear filter:',
} = translations;
const selectionsToDisplay = React.useMemo(() => {
return Object.entries(selections).filter(([_, value]) => Boolean(value));
}, [selections]);
const triggerRefs = React.useRef(new Map<string, HTMLButtonElement>());
const registerTrigger = React.useCallback((facetKey: string, el: HTMLButtonElement | null) => {
if (el) {
triggerRefs.current.set(facetKey, el);
} else {
triggerRefs.current.delete(facetKey);
}
}, []);
const handleDismissFacet = React.useCallback(
(facetKey: string, ev: React.MouseEvent<HTMLButtonElement>) => {
const dismissButton = ev.currentTarget;
const selectionBar = dismissButton.closest('.DocSearch-FacetSelectionBar');
if (selectionBar) {
const dismissButtons = Array.from(selectionBar.querySelectorAll<HTMLButtonElement>('.DocSearch-Chip-Dismiss'));
const index = dismissButtons.indexOf(dismissButton);
const target = dismissButtons[index + 1] ?? dismissButtons[index - 1] ?? triggerRefs.current.get(facetKey);
target?.focus();
}
onSelectionChange(facetKey, '');
},
[onSelectionChange],
);
const handleClearAll = React.useCallback(() => {
triggerRefs.current.values().next().value?.focus();
clearSelections();
}, [clearSelections]);
const facetLabels = React.useMemo(() => new Map(facets.map((f) => [f.key, getFacetLabel(f)])), [facets]);
if (facets.length === 0) {
return null;
}
return (
<>
<div className="DocSearch-FacetBar" role="group" aria-label={facetsAriaLabel}>
{facets.map((facet) => {
const selectedValue = selections[facet.key] ?? '';
return (
<FacetMenu
key={facet.key}
facet={facet}
selectedValue={selectedValue}
defaultValueLabel={defaultValueLabel}
registerTrigger={registerTrigger}
menuTriggerSelectedAriaLabel={facetMenuTriggerAriaLabel}
onSelectionChange={onSelectionChange}
/>
);
})}
</div>
{selectionsToDisplay.length > 0 && (
<div className="DocSearch-FacetSelectionBar" role="group" aria-label={selectedFacetsAriaLabel}>
{selectionsToDisplay.map(([key, value]) => (
<SelectedFacetChip
key={key}
facetKey={key}
facetLabel={facetLabels.get(key) ?? key}
value={value}
dismissAriaLabel={clearFacetAriaLabel}
onDismiss={handleDismissFacet}
/>
))}
<button type="button" className="DocSearch-FacetSelectionBar-Action" onClick={handleClearAll}>
{clearAllLabel}
</button>
</div>
)}
</>
);
});

View file

@ -0,0 +1,34 @@
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { Chip } from '../ui/Chip';
describe('Chip', () => {
afterEach(() => {
cleanup();
});
it('renders its children', () => {
render(<Chip>en</Chip>);
expect(screen.getByText('en')).toBeInTheDocument();
});
describe('Chip.Dismiss', () => {
it('calls onClick when the dismiss button is clicked', () => {
const onDismiss = vi.fn();
render(
<Chip>
en
<Chip.Dismiss onClick={onDismiss} />
</Chip>,
);
fireEvent.click(screen.getByRole('button'));
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -0,0 +1,233 @@
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
import React, { type JSX } from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { FacetBar } from '../FacetBar';
const FACETS = [
{ key: 'language', values: ['en', 'fr'] },
{ key: 'docs_version', label: 'Version', values: ['v1.0', 'v2.0'] },
];
function renderFacetBar(overrides: Partial<React.ComponentProps<typeof FacetBar>> = {}): {
onSelectionChange: ReturnType<typeof vi.fn>;
clearSelections: ReturnType<typeof vi.fn>;
} {
const onSelectionChange = vi.fn();
const clearSelections = vi.fn();
render(
<FacetBar
facets={FACETS}
selections={{}}
clearSelections={clearSelections}
onSelectionChange={onSelectionChange}
{...overrides}
/>,
);
return { onSelectionChange, clearSelections };
}
/**
* Focus-management tests need real unmounts: a stateful wrapper that
* applies selection changes the way the modals do via useDocSearchFacets.
*/
function StatefulFacetBar({ initialSelections }: { initialSelections: Record<string, string> }): JSX.Element {
const [selections, setSelections] = React.useState(initialSelections);
const handleSelectionChange = React.useCallback((facet: string, value: string) => {
setSelections((prev) => {
const next = { ...prev };
if (value === '') delete next[facet];
else next[facet] = value;
return next;
});
}, []);
const clearSelections = React.useCallback(() => setSelections({}), []);
return (
<FacetBar
facets={FACETS}
selections={selections}
clearSelections={clearSelections}
onSelectionChange={handleSelectionChange}
/>
);
}
describe('FacetBar', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
it('renders nothing when there are no facets', () => {
const { container } = render(
<FacetBar facets={[]} selections={{}} clearSelections={vi.fn()} onSelectionChange={vi.fn()} />,
);
expect(container).toBeEmptyDOMElement();
});
it('renders a trigger per facet with humanized or custom labels', () => {
renderFacetBar();
const toolbar = screen.getByRole('group', { name: 'Search filters' });
expect(toolbar).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Language' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Version' })).toBeInTheDocument();
});
it('marks triggers with a selection via data-has-selection', () => {
renderFacetBar({ selections: { language: 'en' } });
expect(screen.getByRole('button', { name: 'Language, en selected' })).toHaveAttribute('data-has-selection', 'true');
expect(screen.getByRole('button', { name: 'Version' })).toHaveAttribute('data-has-selection', 'false');
});
it('calls onSelectionChange when selecting a facet value', async () => {
const { onSelectionChange } = renderFacetBar();
fireEvent.click(screen.getByRole('button', { name: 'Language' }));
const option = await screen.findByRole('menuitemradio', { name: 'Fr' });
fireEvent.click(option);
await waitFor(() => {
expect(onSelectionChange).toHaveBeenCalledWith('language', 'fr');
});
});
it('offers a default option that clears the facet selection', async () => {
const { onSelectionChange } = renderFacetBar({
selections: { language: 'en' },
});
fireEvent.click(screen.getByRole('button', { name: 'Language, en selected' }));
const defaultOption = await screen.findByRole('menuitemradio', {
name: 'All',
});
fireEvent.click(defaultOption);
await waitFor(() => {
expect(onSelectionChange).toHaveBeenCalledWith('language', '');
});
});
it('does not render the selection bar when nothing is selected', () => {
renderFacetBar({ selections: { language: '' } });
expect(screen.queryByRole('group', { name: 'Selected search filters' })).not.toBeInTheDocument();
});
it('renders a chip per selected facet value', () => {
renderFacetBar({ selections: { language: 'en', docs_version: 'v2.0' } });
const selectionBar = screen.getByRole('group', {
name: 'Selected search filters',
});
expect(selectionBar).toHaveTextContent('En');
expect(selectionBar).toHaveTextContent('V2.0');
});
it('clears a single selection when dismissing its chip', () => {
const { onSelectionChange } = renderFacetBar({
selections: { language: 'en', docs_version: 'v2.0' },
});
const dismissLanguage = screen.getByRole('button', {
name: 'Clear filter: En (Language)',
});
fireEvent.click(dismissLanguage);
expect(onSelectionChange).toHaveBeenCalledTimes(1);
expect(onSelectionChange).toHaveBeenCalledWith('language', '');
});
it('clears every selection when clicking "Clear all"', () => {
const { clearSelections } = renderFacetBar({
selections: { language: 'en', docs_version: 'v2.0' },
});
fireEvent.click(screen.getByRole('button', { name: 'Clear all' }));
expect(clearSelections).toHaveBeenCalledTimes(1);
});
it('uses custom translations', () => {
renderFacetBar({
selections: { language: 'en' },
translations: {
clearAllLabel: 'Tout effacer',
facetsAriaLabel: 'Filtres',
selectedFacetsAriaLabel: 'Filtres actifs',
},
});
expect(screen.getByRole('group', { name: 'Filtres' })).toBeInTheDocument();
expect(screen.getByRole('group', { name: 'Filtres actifs' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Tout effacer' })).toBeInTheDocument();
});
describe('focus management', () => {
it('moves focus to the next chip when dismissing a chip with siblings', () => {
render(<StatefulFacetBar initialSelections={{ language: 'en', docs_version: 'v2.0' }} />);
const dismissLanguage = screen.getByRole('button', {
name: 'Clear filter: En (Language)',
});
dismissLanguage.focus();
fireEvent.click(dismissLanguage);
expect(screen.queryByRole('button', { name: 'Clear filter: En (Language)' })).not.toBeInTheDocument();
expect(
screen.getByRole('button', {
name: 'Clear filter: V2.0 (Version)',
}),
).toHaveFocus();
});
it('moves focus to the previous chip when dismissing the last chip in the row', () => {
render(<StatefulFacetBar initialSelections={{ language: 'en', docs_version: 'v2.0' }} />);
const dismissVersion = screen.getByRole('button', {
name: 'Clear filter: V2.0 (Version)',
});
dismissVersion.focus();
fireEvent.click(dismissVersion);
expect(screen.getByRole('button', { name: 'Clear filter: En (Language)' })).toHaveFocus();
});
it("moves focus to the facet's menu trigger when dismissing the only chip", () => {
render(<StatefulFacetBar initialSelections={{ language: 'en' }} />);
const dismiss = screen.getByRole('button', {
name: 'Clear filter: En (Language)',
});
dismiss.focus();
fireEvent.click(dismiss);
// Selection bar unmounted entirely…
expect(screen.queryByRole('group', { name: 'Selected search filters' })).not.toBeInTheDocument();
// …and focus landed on the cleared facet's trigger (label no longer announces a selection)
expect(screen.getByRole('button', { name: 'Language' })).toHaveFocus();
});
it('moves focus to the first facet trigger when clicking "Clear all"', () => {
render(<StatefulFacetBar initialSelections={{ language: 'en', docs_version: 'v2.0' }} />);
const clearAll = screen.getByRole('button', { name: 'Clear all' });
clearAll.focus();
fireEvent.click(clearAll);
expect(screen.queryByRole('group', { name: 'Selected search filters' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Language' })).toHaveFocus();
});
});
});

View file

@ -0,0 +1,24 @@
import React, { type JSX } from 'react';
import { CloseIcon } from '../../icons';
interface ChipProps {
children: React.ReactNode;
}
export function Chip({ children }: ChipProps): JSX.Element {
return <div className="DocSearch-Chip">{children}</div>;
}
function ChipDismiss({ className, type = 'button', ...props }: React.ComponentProps<'button'>): JSX.Element {
const cn = `DocSearch-Chip-Dismiss${className ? ` ${className}` : ''}`;
return (
// eslint-disable-next-line react/button-has-type
<button type={type} className={cn} tabIndex={0} {...props}>
<CloseIcon aria-hidden="true" />
</button>
);
}
Chip.Dismiss = ChipDismiss;

View file

@ -0,0 +1,75 @@
import { Menu as MenuPrimitive } from '@base-ui/react/menu';
import React, { type JSX } from 'react';
import { CheckIcon } from '../../icons';
export function Menu({ ...props }: MenuPrimitive.Root.Props): JSX.Element {
return <MenuPrimitive.Root {...props} />;
}
function MenuTrigger({ className, ...props }: MenuPrimitive.Trigger.Props): JSX.Element {
const cn = `DocSearch-Menu-Trigger${className ? ` ${className}` : ''}`;
return <MenuPrimitive.Trigger className={cn} {...props} />;
}
Menu.Trigger = MenuTrigger;
type MenuPopupProps = MenuPrimitive.Popup.Props &
Pick<MenuPrimitive.Portal.Props, 'container'> &
Pick<MenuPrimitive.Positioner.Props, 'align' | 'alignOffset' | 'collisionBoundary' | 'side' | 'sideOffset'>;
function MenuPopup({
align = 'start',
alignOffset = 0,
side = 'bottom',
sideOffset = 4,
collisionBoundary = 'clipping-ancestors',
container,
...props
}: MenuPopupProps): JSX.Element {
return (
<MenuPrimitive.Portal container={container}>
<MenuPrimitive.Positioner
className="DocSearch-Menu-Positioner"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
collisionBoundary={collisionBoundary}
>
<MenuPrimitive.Popup className="DocSearch-Menu-Popup" {...props} />
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
);
}
Menu.Popup = MenuPopup;
function MenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props): JSX.Element {
return <MenuPrimitive.RadioGroup {...props} />;
}
Menu.RadioGroup = MenuRadioGroup;
function MenuRadioItem({ className, children, ...props }: MenuPrimitive.RadioItem.Props): JSX.Element {
const cn = `DocSearch-Menu-Item DocSearch-Menu-RadioItem${className ? ` ${className}` : ''}`;
return (
<MenuPrimitive.RadioItem className={cn} closeOnClick={true} {...props}>
<span className="DocSearch-Menu-RadioItem-Indicator">
<MenuPrimitive.RadioItemIndicator>
<CheckIcon />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
);
}
Menu.RadioItem = MenuRadioItem;
function MenuGroupLabel({ className, ...props }: MenuPrimitive.GroupLabel.Props): JSX.Element {
const cn = `DocSearch-Menu-GroupLabel${className ? ` ${className}` : ''}`;
return <MenuPrimitive.GroupLabel className={cn} {...props} />;
}
Menu.GroupLabel = MenuGroupLabel;

View file

@ -18,6 +18,7 @@ export type ModalShellProps = {
onClose: () => void;
showDropdown: boolean;
searchBox: React.ReactNode;
filterBar?: React.ReactNode;
screenState: React.ReactNode;
footer: React.ReactNode;
};
@ -32,6 +33,7 @@ export function ModalShell({
onClose,
showDropdown,
searchBox,
filterBar,
screenState,
footer,
}: ModalShellProps): JSX.Element {
@ -60,6 +62,8 @@ export function ModalShell({
{searchBox}
</header>
{filterBar}
{showDropdown && (
<div className="DocSearch-Dropdown" ref={dropdownRef}>
{screenState}

View file

@ -0,0 +1,132 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import type { SearchClient } from 'algoliasearch';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { DocSearchFacet, DocSearchIndex } from '../../DocSearch';
import { useDocSearchFacets } from '../useDocSearchFacets';
describe('useDocSearchFacets', () => {
const search = vi.fn();
const searchClient = { search } as unknown as SearchClient;
const indexes: DocSearchIndex[] = [{ name: 'docs' }];
beforeEach(() => {
vi.clearAllMocks();
search.mockResolvedValue({
results: [
{
facets: {
language: { en: 10, fr: 4 },
version: { 'v1.0': 6 },
},
},
],
});
});
it('exposes only facets that have values', async () => {
const facets: DocSearchFacet[] = [{ key: 'language' }, { key: 'version' }, { key: 'empty' }];
const { result } = renderHook(() => useDocSearchFacets({ facets, indexes, searchClient }));
expect(result.current.visibleFacets).toEqual([]);
await waitFor(() => {
expect(result.current.visibleFacets).toEqual([
{ key: 'language', values: ['en', 'fr'] },
{ key: 'version', values: ['v1.0'] },
]);
});
});
it('updates selections state, ref, and notifies on selection change', () => {
const onSelectionsChange = vi.fn();
const { result } = renderHook(() =>
useDocSearchFacets({
facets: [{ key: 'language' }],
indexes,
searchClient,
onSelectionsChange,
}),
);
act(() => {
result.current.handleFacetSelectionChange('language', 'en');
});
expect(result.current.facetSelections).toEqual({ language: 'en' });
expect(result.current.facetSelectionsRef.current).toEqual({
language: 'en',
});
expect(onSelectionsChange).toHaveBeenCalledTimes(1);
});
it('updates the ref synchronously so getSources closures read fresh selections', () => {
const { result } = renderHook(() =>
useDocSearchFacets({
facets: [{ key: 'language' }],
indexes,
searchClient,
}),
);
let refValueDuringChange: Record<string, string> | undefined;
act(() => {
result.current.handleFacetSelectionChange('language', 'fr');
refValueDuringChange = { ...result.current.facetSelectionsRef.current };
});
expect(refValueDuringChange).toEqual({ language: 'fr' });
});
it('clears all selections and notifies', () => {
const onSelectionsChange = vi.fn();
const { result } = renderHook(() =>
useDocSearchFacets({
facets: [{ key: 'language' }],
indexes,
searchClient,
onSelectionsChange,
}),
);
act(() => {
result.current.handleFacetSelectionChange('language', 'en');
});
act(() => {
result.current.clearFacetSelections();
});
expect(result.current.facetSelections).toEqual({});
expect(result.current.facetSelectionsRef.current).toEqual({});
expect(onSelectionsChange).toHaveBeenCalledTimes(2);
});
it('keeps selection callbacks stable across renders', () => {
const { result, rerender } = renderHook(
({ onSelectionsChange }: { onSelectionsChange: () => void }) =>
useDocSearchFacets({
facets: [{ key: 'language' }],
indexes,
searchClient,
onSelectionsChange,
}),
{ initialProps: { onSelectionsChange: vi.fn() } },
);
const firstHandleChange = result.current.handleFacetSelectionChange;
const firstClear = result.current.clearFacetSelections;
const latestOnSelectionsChange = vi.fn();
rerender({ onSelectionsChange: latestOnSelectionsChange });
expect(result.current.handleFacetSelectionChange).toBe(firstHandleChange);
expect(result.current.clearFacetSelections).toBe(firstClear);
// the latest callback is invoked, not the one from the first render
act(() => {
result.current.handleFacetSelectionChange('language', 'en');
});
expect(latestOnSelectionsChange).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,96 @@
import React from 'react';
import type { FacetBarFacet } from '../components/FacetBar';
import type { DocSearchFacet, DocSearchIndex } from '../DocSearch';
import { useFacetValues } from '../useFacetValues';
import type { useSearchClient } from '../useSearchClient';
import type { FacetSelections } from '../utils/createDocSearchSources';
import { normalizeFacets } from '../utils/facets';
export interface UseDocSearchFacetsProps {
facets?: DocSearchFacet[];
indexes: DocSearchIndex[];
searchClient: ReturnType<typeof useSearchClient>;
/**
* Called after any facet selection change. Modals use this to refresh
* the autocomplete results.
*/
onSelectionsChange?: () => void;
}
export interface UseDocSearchFacetsResult {
/**
* Facets that have at least one value, ready to be rendered by `FacetBar`.
*/
visibleFacets: FacetBarFacet[];
facetSelections: FacetSelections;
/**
* Always-current selections, for consumption inside `getSources` closures.
*/
facetSelectionsRef: React.MutableRefObject<FacetSelections>;
handleFacetSelectionChange: (facet: string, value: string) => void;
clearFacetSelections: () => void;
}
export function useDocSearchFacets({
facets,
indexes,
searchClient,
onSelectionsChange,
}: UseDocSearchFacetsProps): UseDocSearchFacetsResult {
const normalizedFacets = React.useMemo(() => normalizeFacets(facets), [facets]);
const facetValues = useFacetValues({
facets: normalizedFacets,
indexes,
searchClient,
});
const [facetSelections, setFacetSelections] = React.useState<FacetSelections>({});
const facetSelectionsRef = React.useRef(facetSelections);
const onSelectionsChangeRef = React.useRef(onSelectionsChange);
onSelectionsChangeRef.current = onSelectionsChange;
const visibleFacets = React.useMemo(
() =>
normalizedFacets
.map((facet) => ({ ...facet, values: facetValues[facet.key] ?? [] }))
.filter((facet) => facet.values.length > 0),
[facetValues, normalizedFacets],
);
const applySelections = React.useCallback((next: FacetSelections): void => {
facetSelectionsRef.current = next;
setFacetSelections(next);
onSelectionsChangeRef.current?.();
}, []);
const handleFacetSelectionChange = React.useCallback(
(facet: string, value: string): void => {
if (facetSelectionsRef.current[facet] === value) return;
const next = { ...facetSelectionsRef.current };
if (value === '') {
delete next[facet];
} else {
next[facet] = value;
}
applySelections(next);
},
[applySelections],
);
const clearFacetSelections = React.useCallback(() => {
if (Object.keys(facetSelectionsRef.current).length === 0) return;
applySelections({});
}, [applySelections]);
return {
visibleFacets,
facetSelections,
facetSelectionsRef,
handleFacetSelectionChange,
clearFacetSelections,
};
}

View file

@ -0,0 +1,20 @@
import React, { type JSX } from 'react';
export function CheckIcon(): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points="20 6 9 17 4 12" />
</svg>
);
}

View file

@ -0,0 +1,19 @@
import React, { type JSX } from 'react';
export function ChevronIcon(): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points="6 9 12 15 18 9" />
</svg>
);
}

View file

@ -1,8 +1,8 @@
import React, { type JSX } from 'react';
export function CloseIcon(): JSX.Element {
export function CloseIcon({ ...props }: React.ComponentProps<'svg'>): JSX.Element {
return (
<svg width="20" height="20" viewBox="0 0 20 20">
<svg width="20" height="20" viewBox="0 0 20 20" {...props}>
<path
d="M10 10l5.09-5.09L10 10l5.09 5.09L10 10zm0 0L4.91 4.91 10 10l-5.09 5.09L10 10z"
stroke="currentColor"

View file

@ -23,3 +23,5 @@ export * from './MemoryIcon';
export * from './MessageIcon';
export * from './PinIcon';
export * from './BackIcon';
export * from './ChevronIcon';
export * from './CheckIcon';

View file

@ -0,0 +1,85 @@
import type { SearchResponse } from 'algoliasearch/lite';
import React from 'react';
import type { DocSearchFacet, DocSearchIndex } from './DocSearch';
import type { DocSearchHit } from './types';
import type { useSearchClient } from './useSearchClient';
export type FacetValues = Record<string, string[]>;
export function useFacetValues({
facets,
indexes,
searchClient,
}: {
facets: DocSearchFacet[];
indexes: DocSearchIndex[];
searchClient: ReturnType<typeof useSearchClient>;
}): FacetValues {
const [facetValues, setFacetValues] = React.useState<FacetValues>({});
// Derive stable string keys so the effect only re-runs when the actual
// facet keys or index names/searchParameters change, not on every render (the `facets` and
// `indexes` props are recreated on each render and would otherwise loop).
const stableFacetKeys = facets.map((facet) => facet.key).join(',');
const stableIndexes = JSON.stringify(indexes.map((index) => [index.name, index.searchParameters ?? null]));
React.useEffect(() => {
let isMounted = true;
const facetKeys = stableFacetKeys ? stableFacetKeys.split(',') : [];
if (facetKeys.length === 0 || indexes.length === 0) {
setFacetValues({});
return () => {
isMounted = false;
};
}
searchClient
.search<DocSearchHit>({
requests: indexes.map((index) => ({
...(index.searchParameters ?? {}),
indexName: index.name,
query: '',
hitsPerPage: 0,
facets: facetKeys,
})),
})
.then(({ results }) => {
if (!isMounted) {
return;
}
const valuesByFacet = facetKeys.reduce<FacetValues>((acc, facet) => {
acc[facet] = [];
return acc;
}, {});
results.forEach((res) => {
const result = res as SearchResponse<DocSearchHit>;
Object.entries(result.facets ?? {}).forEach(([facet, values]) => {
if (!valuesByFacet[facet]) {
return;
}
valuesByFacet[facet] = Array.from(new Set([...valuesByFacet[facet], ...Object.keys(values)])).sort();
});
});
setFacetValues(valuesByFacet);
})
.catch(() => {
if (isMounted) {
setFacetValues({});
}
});
return () => {
isMounted = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stableFacetKeys, stableIndexes, searchClient]);
return facetValues;
}

View file

@ -0,0 +1,3 @@
export function capitalize(text: string): string {
return `${text.charAt(0).toLocaleUpperCase()}${text.substring(1)}`;
}

View file

@ -1,5 +1,5 @@
import type { AutocompleteSource, AutocompleteState } from '@algolia/autocomplete-core';
import type { SearchResponse } from 'algoliasearch/lite';
import type { SearchParamsObject, SearchResponse } from 'algoliasearch/lite';
import type React from 'react';
import type { DocSearchIndex, DocSearchProps } from '../DocSearch';
@ -18,6 +18,27 @@ export type StoredSearchesLike<TItem> = {
getAll: () => TItem[];
};
export type FacetSelections = Record<string, string>;
export function createFacetFilters(
searchParametersFacetFilters: SearchParamsObject['facetFilters'],
facetSelections: FacetSelections,
): SearchParamsObject['facetFilters'] {
const dynamicFacetFilters = Object.entries(facetSelections)
.filter(([, value]) => value)
.map(([facet, value]) => `${facet}:${value}`);
if (dynamicFacetFilters.length === 0) {
return searchParametersFacetFilters;
}
if (!searchParametersFacetFilters) {
return dynamicFacetFilters;
}
return [...searchParametersFacetFilters, ...dynamicFacetFilters];
}
export function buildNoQuerySources({
recentSearches,
favoriteSearches,
@ -84,6 +105,7 @@ export async function buildQuerySources({
transformItems = identity,
saveRecentSearch,
onClose,
facetSelections,
}: {
query: string;
state: BuildQuerySourcesState;
@ -99,6 +121,7 @@ export async function buildQuerySources({
transformItems?: DocSearchProps['transformItems'];
saveRecentSearch: (item: InternalDocSearchHit) => void;
onClose: () => void;
facetSelections: React.MutableRefObject<FacetSelections>;
}): Promise<Array<AutocompleteSource<InternalDocSearchHit>>> {
const insightsActive = insights;
@ -107,6 +130,7 @@ export async function buildQuerySources({
requests: indexes.map((index) => {
const indexName = index.name;
const searchParams = index.searchParameters;
const facetFilters = createFacetFilters(searchParams?.facetFilters, facetSelections.current);
return {
query,
@ -138,6 +162,7 @@ export async function buildQuerySources({
hitsPerPage: searchParams?.hitsPerPage ?? 20,
clickAnalytics: searchParams?.clickAnalytics ?? insightsActive,
...(searchParams ?? {}),
...(facetFilters ? { facetFilters } : {}),
};
}),
});

View file

@ -0,0 +1,36 @@
import type { DocSearchFacet } from '../DocSearch';
export const MAX_FACETS = 5;
export function normalizeFacets(facets: DocSearchFacet[] = []): DocSearchFacet[] {
const facetsMap = new Map<string, DocSearchFacet>();
for (const facet of facets) {
const key = facet.key.trim().toLowerCase();
if (key.length === 0 || facetsMap.has(key)) {
// eslint-disable-next-line no-continue
continue;
}
if (facetsMap.size >= MAX_FACETS) {
break;
}
facetsMap.set(key, facet);
}
if (process.env.NODE_ENV !== 'production' && facets.length > MAX_FACETS) {
// eslint-disable-next-line no-console
console.warn(`DocSearch supports a maximum of ${MAX_FACETS} facets. Extra facets were ignored.`);
}
return Array.from(facetsMap.values());
}
export function getFacetLabel(facet: DocSearchFacet): string {
if (facet.label) {
return facet.label;
}
return facet.key.replace(/[._-]+/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
}

View file

@ -1,3 +1,4 @@
export * from './capitalize';
export * from './collections';
export * from './decodeHtmlEntities';
export * from './getHitItemBreadcrumbs';