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:
parent
96eda2362e
commit
ef2bec0e20
29 changed files with 1621 additions and 90 deletions
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
112
packages/docsearch-react/src/__tests__/useFacetValues.test.ts
Normal file
112
packages/docsearch-react/src/__tests__/useFacetValues.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
248
packages/docsearch-react/src/components/FacetBar.tsx
Normal file
248
packages/docsearch-react/src/components/FacetBar.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
24
packages/docsearch-react/src/components/ui/Chip.tsx
Normal file
24
packages/docsearch-react/src/components/ui/Chip.tsx
Normal 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;
|
||||
75
packages/docsearch-react/src/components/ui/Menu.tsx
Normal file
75
packages/docsearch-react/src/components/ui/Menu.tsx
Normal 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;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
96
packages/docsearch-react/src/hooks/useDocSearchFacets.ts
Normal file
96
packages/docsearch-react/src/hooks/useDocSearchFacets.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
20
packages/docsearch-react/src/icons/CheckIcon.tsx
Normal file
20
packages/docsearch-react/src/icons/CheckIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
packages/docsearch-react/src/icons/ChevronIcon.tsx
Normal file
19
packages/docsearch-react/src/icons/ChevronIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -23,3 +23,5 @@ export * from './MemoryIcon';
|
|||
export * from './MessageIcon';
|
||||
export * from './PinIcon';
|
||||
export * from './BackIcon';
|
||||
export * from './ChevronIcon';
|
||||
export * from './CheckIcon';
|
||||
|
|
|
|||
85
packages/docsearch-react/src/useFacetValues.ts
Normal file
85
packages/docsearch-react/src/useFacetValues.ts
Normal 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;
|
||||
}
|
||||
3
packages/docsearch-react/src/utils/capitalize.ts
Normal file
3
packages/docsearch-react/src/utils/capitalize.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function capitalize(text: string): string {
|
||||
return `${text.charAt(0).toLocaleUpperCase()}${text.substring(1)}`;
|
||||
}
|
||||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
36
packages/docsearch-react/src/utils/facets.ts
Normal file
36
packages/docsearch-react/src/utils/facets.ts
Normal 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());
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './capitalize';
|
||||
export * from './collections';
|
||||
export * from './decodeHtmlEntities';
|
||||
export * from './getHitItemBreadcrumbs';
|
||||
|
|
|
|||
Loading…
Reference in a new issue