diff --git a/AGENTS.md b/AGENTS.md index 0b131c13..9b3bf35d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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; }; diff --git a/bundlesize.config.json b/bundlesize.config.json index 75d9d09b..b884de51 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -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", diff --git a/examples/demo-react/src/examples/basic-askai.tsx b/examples/demo-react/src/examples/basic-askai.tsx index e96a77df..7748a28e 100644 --- a/examples/demo-react/src/examples/basic-askai.tsx +++ b/examples/demo-react/src/examples/basic-askai.tsx @@ -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} diff --git a/examples/demo-react/src/examples/basic.tsx b/examples/demo-react/src/examples/basic.tsx index ad0f9f10..3dbc5dc8 100644 --- a/examples/demo-react/src/examples/basic.tsx +++ b/examples/demo-react/src/examples/basic.tsx @@ -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' }, + ]} /> ); } diff --git a/packages/docsearch-css/src/_variables.css b/packages/docsearch-css/src/_variables.css index ccbde993..a43882f3 100644 --- a/packages/docsearch-css/src/_variables.css +++ b/packages/docsearch-css/src/_variables.css @@ -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); } diff --git a/packages/docsearch-css/src/modal.css b/packages/docsearch-css/src/modal.css index 3055911e..f49501e5 100644 --- a/packages/docsearch-css/src/modal.css +++ b/packages/docsearch-css/src/modal.css @@ -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); -} diff --git a/packages/docsearch-react/src/AskAiScreenState.tsx b/packages/docsearch-react/src/AskAiScreenState.tsx index b380f187..a4d06fa1 100644 --- a/packages/docsearch-react/src/AskAiScreenState.tsx +++ b/packages/docsearch-react/src/AskAiScreenState.tsx @@ -113,7 +113,7 @@ export const AskAiScreenState = React.memo( return ( <> - {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 )} diff --git a/packages/docsearch-react/src/DocSearch.tsx b/packages/docsearch-react/src/DocSearch.tsx index 6d39f30b..e5ae381a 100644 --- a/packages/docsearch-react/src/DocSearch.tsx +++ b/packages/docsearch-react/src/DocSearch.tsx @@ -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; + /** + * 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. */ diff --git a/packages/docsearch-react/src/DocSearchAskAiModal.tsx b/packages/docsearch-react/src/DocSearchAskAiModal.tsx index 190b01ea..695f5d01 100644 --- a/packages/docsearch-react/src/DocSearchAskAiModal.tsx +++ b/packages/docsearch-react/src/DocSearchAskAiModal.tsx @@ -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>({ 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, + 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, - 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 !== '' ? ( + + ) : null + } screenState={ & 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>({ 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 !== '' ? ( + + ) : null + } screenState={ { + 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(); + }); +}); diff --git a/packages/docsearch-react/src/__tests__/utils.test.ts b/packages/docsearch-react/src/__tests__/utils.test.ts index b34156af..5c0314ab 100644 --- a/packages/docsearch-react/src/__tests__/utils.test.ts +++ b/packages/docsearch-react/src/__tests__/utils.test.ts @@ -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 = { diff --git a/packages/docsearch-react/src/components/FacetBar.tsx b/packages/docsearch-react/src/components/FacetBar.tsx new file mode 100644 index 00000000..162f3776 --- /dev/null +++ b/packages/docsearch-react/src/components/FacetBar.tsx @@ -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 ( + + + {capitalize(label)} + + + + + + {defaultValueLabel} + + {facet.values.map((value) => ( + + {capitalize(value)} + + ))} + + + + ); +}); + +interface SelectedFacetChipProps { + facetKey: string; + facetLabel: string; + value: string; + dismissAriaLabel: string; + onDismiss: (facetKey: string, event: React.MouseEvent) => void; +} + +const SelectedFacetChip = React.memo(function SelectedFacetChip({ + facetKey, + facetLabel, + value, + dismissAriaLabel, + onDismiss, +}: SelectedFacetChipProps): JSX.Element { + const handleDismissFacet = React.useCallback( + (e: React.MouseEvent) => { + onDismiss(facetKey, e); + }, + [facetKey, onDismiss], + ); + + return ( + + {capitalize(value)} + + + ); +}); + +interface FacetBarProps { + facets: FacetBarFacet[]; + selections: Record; + 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()); + + 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) => { + const dismissButton = ev.currentTarget; + const selectionBar = dismissButton.closest('.DocSearch-FacetSelectionBar'); + + if (selectionBar) { + const dismissButtons = Array.from(selectionBar.querySelectorAll('.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 ( + <> +
+ {facets.map((facet) => { + const selectedValue = selections[facet.key] ?? ''; + + return ( + + ); + })} +
+ {selectionsToDisplay.length > 0 && ( +
+ {selectionsToDisplay.map(([key, value]) => ( + + ))} + +
+ )} + + ); +}); diff --git a/packages/docsearch-react/src/components/__tests__/Chip.test.tsx b/packages/docsearch-react/src/components/__tests__/Chip.test.tsx new file mode 100644 index 00000000..99b5642c --- /dev/null +++ b/packages/docsearch-react/src/components/__tests__/Chip.test.tsx @@ -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(en); + + expect(screen.getByText('en')).toBeInTheDocument(); + }); + + describe('Chip.Dismiss', () => { + it('calls onClick when the dismiss button is clicked', () => { + const onDismiss = vi.fn(); + render( + + en + + , + ); + + fireEvent.click(screen.getByRole('button')); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/docsearch-react/src/components/__tests__/FacetBar.test.tsx b/packages/docsearch-react/src/components/__tests__/FacetBar.test.tsx new file mode 100644 index 00000000..722235f6 --- /dev/null +++ b/packages/docsearch-react/src/components/__tests__/FacetBar.test.tsx @@ -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> = {}): { + onSelectionChange: ReturnType; + clearSelections: ReturnType; +} { + const onSelectionChange = vi.fn(); + const clearSelections = vi.fn(); + render( + , + ); + 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 }): 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 ( + + ); +} + +describe('FacetBar', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it('renders nothing when there are no facets', () => { + const { container } = render( + , + ); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); + }); +}); diff --git a/packages/docsearch-react/src/components/ui/Chip.tsx b/packages/docsearch-react/src/components/ui/Chip.tsx new file mode 100644 index 00000000..797c33e5 --- /dev/null +++ b/packages/docsearch-react/src/components/ui/Chip.tsx @@ -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
{children}
; +} + +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 + + ); +} + +Chip.Dismiss = ChipDismiss; diff --git a/packages/docsearch-react/src/components/ui/Menu.tsx b/packages/docsearch-react/src/components/ui/Menu.tsx new file mode 100644 index 00000000..4c4b92bc --- /dev/null +++ b/packages/docsearch-react/src/components/ui/Menu.tsx @@ -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 ; +} + +function MenuTrigger({ className, ...props }: MenuPrimitive.Trigger.Props): JSX.Element { + const cn = `DocSearch-Menu-Trigger${className ? ` ${className}` : ''}`; + return ; +} + +Menu.Trigger = MenuTrigger; + +type MenuPopupProps = MenuPrimitive.Popup.Props & + Pick & + Pick; + +function MenuPopup({ + align = 'start', + alignOffset = 0, + side = 'bottom', + sideOffset = 4, + collisionBoundary = 'clipping-ancestors', + container, + ...props +}: MenuPopupProps): JSX.Element { + return ( + + + + + + ); +} + +Menu.Popup = MenuPopup; + +function MenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props): JSX.Element { + return ; +} + +Menu.RadioGroup = MenuRadioGroup; + +function MenuRadioItem({ className, children, ...props }: MenuPrimitive.RadioItem.Props): JSX.Element { + const cn = `DocSearch-Menu-Item DocSearch-Menu-RadioItem${className ? ` ${className}` : ''}`; + return ( + + + + + + + {children} + + ); +} + +Menu.RadioItem = MenuRadioItem; + +function MenuGroupLabel({ className, ...props }: MenuPrimitive.GroupLabel.Props): JSX.Element { + const cn = `DocSearch-Menu-GroupLabel${className ? ` ${className}` : ''}`; + return ; +} + +Menu.GroupLabel = MenuGroupLabel; diff --git a/packages/docsearch-react/src/components/ui/ModalShell.tsx b/packages/docsearch-react/src/components/ui/ModalShell.tsx index 05f4c7e9..2c5653d7 100644 --- a/packages/docsearch-react/src/components/ui/ModalShell.tsx +++ b/packages/docsearch-react/src/components/ui/ModalShell.tsx @@ -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} + {filterBar} + {showDropdown && (
{screenState} diff --git a/packages/docsearch-react/src/hooks/__tests__/useDocSearchFacets.test.tsx b/packages/docsearch-react/src/hooks/__tests__/useDocSearchFacets.test.tsx new file mode 100644 index 00000000..40c32c58 --- /dev/null +++ b/packages/docsearch-react/src/hooks/__tests__/useDocSearchFacets.test.tsx @@ -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 | 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); + }); +}); diff --git a/packages/docsearch-react/src/hooks/useDocSearchFacets.ts b/packages/docsearch-react/src/hooks/useDocSearchFacets.ts new file mode 100644 index 00000000..5a8029ac --- /dev/null +++ b/packages/docsearch-react/src/hooks/useDocSearchFacets.ts @@ -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; + /** + * 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; + 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({}); + 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, + }; +} diff --git a/packages/docsearch-react/src/icons/CheckIcon.tsx b/packages/docsearch-react/src/icons/CheckIcon.tsx new file mode 100644 index 00000000..b0f06f38 --- /dev/null +++ b/packages/docsearch-react/src/icons/CheckIcon.tsx @@ -0,0 +1,20 @@ +import React, { type JSX } from 'react'; + +export function CheckIcon(): JSX.Element { + return ( + + ); +} diff --git a/packages/docsearch-react/src/icons/ChevronIcon.tsx b/packages/docsearch-react/src/icons/ChevronIcon.tsx new file mode 100644 index 00000000..e7189ccd --- /dev/null +++ b/packages/docsearch-react/src/icons/ChevronIcon.tsx @@ -0,0 +1,19 @@ +import React, { type JSX } from 'react'; + +export function ChevronIcon(): JSX.Element { + return ( + + ); +} diff --git a/packages/docsearch-react/src/icons/CloseIcon.tsx b/packages/docsearch-react/src/icons/CloseIcon.tsx index 8c8c173a..5da1b318 100644 --- a/packages/docsearch-react/src/icons/CloseIcon.tsx +++ b/packages/docsearch-react/src/icons/CloseIcon.tsx @@ -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 ( - + ; + +export function useFacetValues({ + facets, + indexes, + searchClient, +}: { + facets: DocSearchFacet[]; + indexes: DocSearchIndex[]; + searchClient: ReturnType; +}): FacetValues { + const [facetValues, setFacetValues] = React.useState({}); + + // 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({ + requests: indexes.map((index) => ({ + ...(index.searchParameters ?? {}), + indexName: index.name, + query: '', + hitsPerPage: 0, + facets: facetKeys, + })), + }) + .then(({ results }) => { + if (!isMounted) { + return; + } + + const valuesByFacet = facetKeys.reduce((acc, facet) => { + acc[facet] = []; + return acc; + }, {}); + + results.forEach((res) => { + const result = res as SearchResponse; + 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; +} diff --git a/packages/docsearch-react/src/utils/capitalize.ts b/packages/docsearch-react/src/utils/capitalize.ts new file mode 100644 index 00000000..a5bb778b --- /dev/null +++ b/packages/docsearch-react/src/utils/capitalize.ts @@ -0,0 +1,3 @@ +export function capitalize(text: string): string { + return `${text.charAt(0).toLocaleUpperCase()}${text.substring(1)}`; +} diff --git a/packages/docsearch-react/src/utils/createDocSearchSources.ts b/packages/docsearch-react/src/utils/createDocSearchSources.ts index 01c797d0..ed7da03c 100644 --- a/packages/docsearch-react/src/utils/createDocSearchSources.ts +++ b/packages/docsearch-react/src/utils/createDocSearchSources.ts @@ -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 = { getAll: () => TItem[]; }; +export type FacetSelections = Record; + +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; }): Promise>> { 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 } : {}), }; }), }); diff --git a/packages/docsearch-react/src/utils/facets.ts b/packages/docsearch-react/src/utils/facets.ts new file mode 100644 index 00000000..e3d0df74 --- /dev/null +++ b/packages/docsearch-react/src/utils/facets.ts @@ -0,0 +1,36 @@ +import type { DocSearchFacet } from '../DocSearch'; + +export const MAX_FACETS = 5; + +export function normalizeFacets(facets: DocSearchFacet[] = []): DocSearchFacet[] { + const facetsMap = new Map(); + + 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()); +} diff --git a/packages/docsearch-react/src/utils/index.ts b/packages/docsearch-react/src/utils/index.ts index 369dde9a..50f9d7d6 100644 --- a/packages/docsearch-react/src/utils/index.ts +++ b/packages/docsearch-react/src/utils/index.ts @@ -1,3 +1,4 @@ +export * from './capitalize'; export * from './collections'; export * from './decodeHtmlEntities'; export * from './getHitItemBreadcrumbs';