{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 (
-