1
0
Fork 0

feat(composable-api): Introduce new Composable API (#2779)

* feat(composable-api): Step 1, new core package, pull basic state up higer (#2771)

* feat(composable-api): Step 1, new core package, pull basic state up higher

* Package cleanup, update core Readme

* Cleanup to fix weird import behavior

* Fix dependencies

* Try fixing deps again

* Track new package dist in CI

* Make sure to restore core package

* feat(composable-api): Step 2, composable DocSearchButton (#2772)

* feat(composable-api): Step 1, new core package, pull basic state up higher

* Package cleanup, update core Readme

* Cleanup to fix weird import behavior

* Fix dependencies

* Try fixing deps again

* Track new package dist in CI

* Make sure to restore core package

* feat(composable-api): Step 2, move DocSearchButton to be composable

* Implement keyboard shortcuts props

* Set modal package version

* Render new DocSearchButton in react package

* Bump allowed bundle size for now

* feat(composable-api): Step 3, composable DocSearchModal (#2774)

* feat(composable-api): Step 1, new core package, pull basic state up higher

* Package cleanup, update core Readme

* Cleanup to fix weird import behavior

* Fix dependencies

* Try fixing deps again

* Track new package dist in CI

* Make sure to restore core package

* feat(composable-api): Step 2, move DocSearchButton to be composable

* Implement keyboard shortcuts props

* Set modal package version

* Render new DocSearchButton in react package

* Bump allowed bundle size for now

* feat(composable-api): Step 3, make DocSearchModal composable

* Clean up needed dependencies, add more bundle checks

* fix: CI

* add test cases to core package

* add test cases to modal package

* fixes after rebase

* fix: lint

* Memoize modal props to stabalize rendering

* Dont track isModalActive in props

* bump @docsearch/modal bundlesize

* remove icons dir from @docsearch-modal

* update version definition for @docsearch-react and @docsearch-modal

* fix: lint
This commit is contained in:
Paul Jankowski 2025-10-20 16:22:53 -04:00 committed by GitHub
parent 5381e76126
commit 5e9bdfed70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1975 additions and 439 deletions

View file

@ -25,10 +25,14 @@ aliases:
mkdir -p packages/docsearch-react/dist
mkdir -p packages/docsearch-js/dist
mkdir -p packages/docsearch-css/dist
mkdir -p packages/docsearch-core/dist
mkdir -p packages/docsearch-modal/dist
cp -R /tmp/workspace/packages/docsearch-react/dist packages/docsearch-react
cp -R /tmp/workspace/packages/docsearch-js/dist packages/docsearch-js
cp -R /tmp/workspace/packages/docsearch-css/dist packages/docsearch-css
cp -R /tmp/workspace/packages/docsearch-core/dist packages/docsearch-core
cp -R /tmp/workspace/packages/docsearch-modal/dist packages/docsearch-modal
defaults: &defaults
working_directory: ~/docsearch
@ -69,10 +73,14 @@ jobs:
mkdir -p /tmp/workspace/packages/docsearch-react/dist
mkdir -p /tmp/workspace/packages/docsearch-js/dist
mkdir -p /tmp/workspace/packages/docsearch-css/dist
mkdir -p /tmp/workspace/packages/docsearch-core/dist
mkdir -p /tmp/workspace/packages/docsearch-modal/dist
cp -R packages/docsearch-react/dist /tmp/workspace/packages/docsearch-react
cp -R packages/docsearch-js/dist /tmp/workspace/packages/docsearch-js
cp -R packages/docsearch-css/dist /tmp/workspace/packages/docsearch-css
cp -R packages/docsearch-core/dist /tmp/workspace/packages/docsearch-core
cp -R packages/docsearch-modal/dist /tmp/workspace/packages/docsearch-modal
- persist_to_workspace:
root: *workspace_root
paths:

View file

@ -11,6 +11,14 @@
{
"path": "packages/docsearch-js/dist/umd/index.js",
"maxSize": "128 kB"
},
{
"path": "packages/docsearch-core/dist/umd/index.js",
"maxSize": "3 kB"
},
{
"path": "packages/docsearch-modal/dist/umd/index.js",
"maxSize": "113 kB"
}
]
}

View file

@ -11,7 +11,9 @@
"preview": "vite preview"
},
"dependencies": {
"@docsearch/core": "workspace:*",
"@docsearch/css": "workspace:*",
"@docsearch/modal": "workspace:*",
"@docsearch/react": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0"

View file

@ -7,6 +7,8 @@ import '@docsearch/css/dist/style.css';
import Basic from './examples/basic';
import BasicAskAI from './examples/basic-askai';
import Composable from './examples/composable';
import DynamicImportModal from './examples/dynamic-import-modal';
import MultiIndex from './examples/multi-index';
import WHitComponent from './examples/w-hit-component';
import WTransformItems from './examples/w-hit-transformItems';
@ -57,6 +59,20 @@ function App(): JSX.Element {
</div>
</section>
<section className="demo-section">
<p className="section-description">composable</p>
<div className="search-wrapper">
<Composable />
</div>
</section>
<section className="demo-section">
<p className="section-description">dynamically imported modal</p>
<div className="search-wrapper">
<DynamicImportModal />
</div>
</section>
<section className="demo-section">
<p className="section-description">results footer component</p>
<div className="search-wrapper">

View file

@ -0,0 +1,23 @@
/* eslint-disable react/react-in-jsx-scope */
import { DocSearch } from '@docsearch/core';
import { DocSearchButton, DocSearchModal } from '@docsearch/modal';
import { type JSX } from 'react';
export default function Composable(): JSX.Element {
return (
<DocSearch>
<DocSearchButton translations={{ buttonText: 'Composable API' }} />
<DocSearchModal
indexName="docsearch"
appId="PMZUYBQDAK"
apiKey="24b09689d5b4223813d9b8e48563c8f6"
askAi={{
assistantId: 'askAIDemo',
searchParameters: {
facetFilters: ['language:en'],
},
}}
/>
</DocSearch>
);
}

View file

@ -0,0 +1,114 @@
/* eslint-disable react/react-in-jsx-scope */
import { useDocSearchKeyboardEvents } from '@docsearch/core/useDocSearchKeyboardEvents';
import { DocSearchButton } from '@docsearch/react/button';
import type { DocSearchModal as DocSearchModalType } from '@docsearch/react/modal';
import { useCallback, useRef, useState, type JSX } from 'react';
import { createPortal } from 'react-dom';
let DocSearchModal: typeof DocSearchModalType | null = null;
function importDocSearchModalIfNeeded(): Promise<void> {
if (DocSearchModal) {
return Promise.resolve();
}
// eslint-disable-next-line import/dynamic-import-chunkname
return Promise.all([import('@docsearch/react/modal')]).then(([{ DocSearchModal: Modal }]) => {
DocSearchModal = Modal;
});
}
function DocSearch(): JSX.Element {
const [isOpen, setIsOpen] = useState(false);
const [initialQuery, setInitialQuery] = useState<string | undefined>(undefined);
const [isAskAiActive, setIsAskAiActive] = useState(false);
const searchContainer = useRef<HTMLDivElement | null>(null);
const searchButtonRef = useRef<HTMLButtonElement | null>(null);
const prepareSearchContainer = useCallback(() => {
if (!searchContainer.current) {
const divElement = document.createElement('div');
searchContainer.current = divElement;
document.body.insertBefore(divElement, document.body.firstChild);
}
}, []);
const openModal = useCallback(() => {
prepareSearchContainer();
importDocSearchModalIfNeeded().then(() => setIsOpen(true));
}, [prepareSearchContainer]);
const closeModal = useCallback(() => {
setIsOpen(false);
searchButtonRef.current?.focus();
setInitialQuery(undefined);
}, []);
const handleInput = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'f' && (event.metaKey || event.ctrlKey)) {
// ignore browser's ctrl+f
return;
}
// prevents duplicate key insertion in the modal input
event.preventDefault();
setInitialQuery(event.key);
openModal();
},
[openModal],
);
const toggleAskAi = (active: boolean): void => {
setIsAskAiActive(active);
};
useDocSearchKeyboardEvents({
isOpen,
onOpen: openModal,
onClose: closeModal,
onInput: handleInput,
searchButtonRef,
isAskAiActive,
onAskAiToggle: toggleAskAi,
});
return (
<>
<DocSearchButton
ref={searchButtonRef}
translations={{ buttonText: 'Dynamic modal search' }}
onTouchStart={importDocSearchModalIfNeeded}
onFocus={importDocSearchModalIfNeeded}
onMouseOver={importDocSearchModalIfNeeded}
onClick={openModal}
/>
{isOpen &&
DocSearchModal &&
searchContainer.current &&
createPortal(
<DocSearchModal
indexName="docsearch"
appId="PMZUYBQDAK"
apiKey="24b09689d5b4223813d9b8e48563c8f6"
askAi={{
assistantId: 'askAIDemo',
searchParameters: {
facetFilters: ['language:en'],
},
}}
initialScrollY={window.scrollY}
initialQuery={initialQuery}
isAskAiActive={isAskAiActive}
canHandleAskAi={true}
onClose={closeModal}
onAskAiToggle={toggleAskAi}
/>,
searchContainer.current,
)}
</>
);
}
export default function DynamicImportModal(): JSX.Element {
return <DocSearch />;
}

View file

@ -0,0 +1,3 @@
# @docsearch/core
Core logic and state package for [DocSearch](http://docsearch.algolia.com/), the best search experience for docs.

View file

@ -0,0 +1,23 @@
export default (api) => {
const isTest = api.env('test');
const targets = {};
if (isTest) {
targets.node = true;
} else {
targets.browsers = ['last 2 versions', 'ie >= 9'];
}
return {
presets: [
'@babel/preset-typescript',
[
'@babel/preset-env',
{
targets,
},
],
],
plugins: [['@babel/plugin-transform-react-jsx']],
};
};

View file

@ -0,0 +1 @@
export { DocSearch, useDocSearch } from './dist/esm/index.js';

View file

@ -0,0 +1,69 @@
{
"name": "@docsearch/core",
"description": "Core package logic for DocSearch",
"version": "1.0.0",
"license": "MIT",
"homepage": "https://docsearch.algolia.com",
"repository": {
"type": "git",
"url": "git+https://github.com/algolia/docsearch.git",
"directory": "packages/docsearch-react"
},
"author": {
"name": "Algolia, Inc.",
"url": "https://www.algolia.com"
},
"sideEffects": false,
"files": [
"dist/",
"index.js",
"useTheme.js",
"useDocSearchKeyboardEvents.js",
"useKeyboardShortcuts.js"
],
"exports": {
".": "./dist/esm/index.js",
"./useTheme": "./dist/esm/useTheme.js",
"./useDocSearchKeyboardEvents": "./dist/esm/useDocSearchKeyboardEvents.js",
"./useKeyboardShortcuts": "./dist/esm/useKeyboardShortcuts.js"
},
"source": "src/index.ts",
"types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js",
"main": "dist/umd/index.js",
"umd:main": "dist/umd/index.js",
"unpkg": "dist/umd/index.js",
"jsdelivr": "dist/umd/index.js",
"scripts": {
"build:clean": "rm -rf ./dist",
"build:clean-types": "rm -rf ./dist/esm/types",
"build:types": "tsc -p ./tsconfig.declaration.json --outDir ./dist/esm/types",
"build": "yarn build:clean && yarn build:types && rollup --config --bundleConfigAsCjs && yarn build:clean-types",
"on:change": "yarn build",
"watch": "nodemon --watch src --ext ts,tsx,js,jsx,json --ignore dist/ --ignore node_modules/ --verbose --delay 250ms --exec \"yarn on:change\""
},
"devDependencies": {
"@rollup/plugin-replace": "6.0.2",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.2.0",
"nodemon": "^3.1.0",
"rollup-plugin-dts": "^6.2.1",
"vitest": "3.0.2"
},
"peerDependencies": {
"@types/react": ">= 16.8.0 < 20.0.0",
"react": ">= 16.8.0 < 20.0.0",
"react-dom": ">= 16.8.0 < 20.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
}

View file

@ -0,0 +1,82 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import commonjs from '@rollup/plugin-commonjs';
import replace from '@rollup/plugin-replace';
import { dts } from 'rollup-plugin-dts';
import { plugins } from '../../rollup.base.config';
import { getBundleBanner } from '../../scripts/getBundleBanner';
import pkg from './package.json';
const sourcePlugins = [
commonjs(),
...plugins,
replace({
preventAssignment: true,
'process.env.NODE_ENV': JSON.stringify('production'),
}),
];
function sourceOutput(fileName) {
return [
{
globals: {
react: 'React',
'react-dom': 'ReactDOM',
},
file: `dist/umd/${fileName}`,
format: 'umd',
sourcemap: true,
name: pkg.name,
banner: getBundleBanner(pkg),
},
{ dir: 'dist/esm', format: 'es' },
];
}
export default [
{
input: 'src/index.ts',
external: ['react', 'react-dom'],
output: sourceOutput('index.js'),
plugins: sourcePlugins,
},
{
input: 'src/useTheme.ts',
external: ['react', 'react-dom'],
output: sourceOutput('useTheme.js'),
plugins: sourcePlugins,
},
{
input: 'src/useDocSearchKeyboardEvents.ts',
external: ['react', 'react-dom'],
output: sourceOutput('useDocSearchKeyboardEvents.js'),
plugins: sourcePlugins,
},
{
input: 'src/useKeyboardShortcuts.ts',
external: ['react', 'react-dom'],
output: sourceOutput('useKeyboardShortcuts.js'),
plugins: sourcePlugins,
},
{
input: 'dist/esm/types/index.d.ts',
output: [{ file: 'dist/esm/index.d.ts', format: 'es' }],
plugins: [dts()],
},
{
input: 'dist/esm/types/useTheme.d.ts',
output: [{ file: 'dist/esm/useTheme.d.ts', format: 'es' }],
plugins: [dts()],
},
{
input: 'dist/esm/types/useDocSearchKeyboardEvents.d.ts',
output: [{ file: 'dist/esm/useDocSearchKeyboardEvents.d.ts', format: 'es' }],
plugins: [dts()],
},
{
input: 'dist/esm/types/useKeyboardShortcuts.d.ts',
output: [{ file: 'dist/esm/useKeyboardShortcuts.d.ts', format: 'es' }],
plugins: [dts()],
},
];

View file

@ -0,0 +1,120 @@
import type { JSX } from 'react';
import React from 'react';
import { useDocSearchKeyboardEvents } from './useDocSearchKeyboardEvents';
import { useKeyboardShortcuts } from './useKeyboardShortcuts';
import type { KeyboardShortcuts } from './useKeyboardShortcuts.ts';
import type { DocSearchTheme } from './useTheme';
import { useTheme } from './useTheme';
export type DocSearchState = 'modal-askai' | 'modal-search' | 'ready';
export interface DocSearchContext {
docsearchState: DocSearchState;
setDocsearchState: (newState: DocSearchState) => void;
searchButtonRef: React.RefObject<HTMLButtonElement | null>;
initialQuery: string;
keyboardShortcuts: Required<KeyboardShortcuts>;
openModal: () => void;
closeModal: () => void;
isAskAiActive: boolean;
isModalActive: boolean;
onAskAiToggle: (active: boolean) => void;
}
export interface DocSearchProps {
children: Array<JSX.Element | null> | JSX.Element | React.ReactNode | null;
theme?: DocSearchTheme;
initialQuery?: string;
keyboardShortcuts?: KeyboardShortcuts;
}
const Context = React.createContext<DocSearchContext | undefined>(undefined);
Context.displayName = 'DocSearchContext';
export function DocSearch({ children, theme, ...props }: DocSearchProps): JSX.Element {
const [docsearchState, setDocsearchState] = React.useState<DocSearchState>('ready');
const [initialQuery, setInitialQuery] = React.useState<string>(props.initialQuery || '');
const searchButtonRef = React.useRef<HTMLButtonElement>(null);
const keyboardShortcuts = useKeyboardShortcuts(props.keyboardShortcuts);
const isModalActive = ['modal-search', 'modal-askai'].includes(docsearchState);
const isAskAiActive = docsearchState === 'modal-askai';
const openModal = React.useCallback((): void => {
setDocsearchState('modal-search');
}, []);
const closeModal = React.useCallback((): void => {
setDocsearchState('ready');
searchButtonRef.current?.focus();
setInitialQuery(props.initialQuery ?? '');
}, [setDocsearchState, props.initialQuery]);
const onAskAiToggle = React.useCallback(
(active: boolean): void => {
setDocsearchState(active ? 'modal-askai' : 'modal-search');
},
[setDocsearchState],
);
const onInput = React.useCallback(
(event: KeyboardEvent): void => {
setDocsearchState('modal-search');
setInitialQuery(event.key);
},
[setDocsearchState, setInitialQuery],
);
useTheme({ theme });
useDocSearchKeyboardEvents({
isOpen: isModalActive,
onOpen: openModal,
onClose: closeModal,
onAskAiToggle,
onInput,
isAskAiActive,
searchButtonRef,
keyboardShortcuts,
});
const value: DocSearchContext = React.useMemo(
() => ({
docsearchState,
setDocsearchState,
searchButtonRef,
initialQuery,
keyboardShortcuts,
openModal,
closeModal,
isAskAiActive,
isModalActive,
onAskAiToggle,
}),
[
docsearchState,
searchButtonRef,
initialQuery,
keyboardShortcuts,
openModal,
closeModal,
isAskAiActive,
isModalActive,
onAskAiToggle,
],
);
return <Context.Provider value={value}>{children}</Context.Provider>;
}
DocSearch.displayName = 'DocSearch';
export function useDocSearch(): DocSearchContext {
const ctx = React.useContext(Context);
if (ctx === undefined) {
throw new Error('`useDocSearch` must be used within the `DocSearch` provider');
}
return ctx;
}

View file

@ -0,0 +1,444 @@
import type { RenderHookResult, RenderResult } from '@testing-library/react';
import { render, screen, cleanup, renderHook, act, fireEvent } from '@testing-library/react';
import type { JSX } from 'react';
import React, { useRef } from 'react';
import { describe, it, expect, afterEach, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { DocSearch, useDocSearch, type DocSearchContext, type DocSearchProps } from '../DocSearch';
import { useDocSearchKeyboardEvents } from '../useDocSearchKeyboardEvents';
import { useKeyboardShortcuts } from '../useKeyboardShortcuts';
import { useTheme } from '../useTheme';
type DocSearchPropsNoChildren = Omit<DocSearchProps, 'children'>;
const renderWithProvider = (comp: React.ReactNode, props: DocSearchPropsNoChildren = {}): RenderResult =>
render(comp, {
wrapper({ children }) {
return <DocSearch {...props}>{children}</DocSearch>;
},
});
const renderCustomHook = (props: DocSearchPropsNoChildren = {}): RenderHookResult<DocSearchContext, never> =>
renderHook(() => useDocSearch(), {
wrapper({ children }) {
return <DocSearch {...props}>{children}</DocSearch>;
},
});
const StateRender = (): JSX.Element => {
const { docsearchState } = useDocSearch();
return <p>State: {docsearchState}</p>;
};
describe('@docsearch/core', () => {
afterEach(() => {
cleanup();
});
describe('DocSearch', () => {
it('renders without errors', () => {
renderWithProvider(<p>Hello world!</p>);
expect(screen.getByText('Hello world!')).toBeInTheDocument();
});
it('provides state to children', () => {
renderWithProvider(<StateRender />);
expect(screen.getByText('State: ready')).toBeInTheDocument();
});
it('updates state from children', async () => {
const Comp = (): JSX.Element => {
const { docsearchState, openModal } = useDocSearch();
return (
<>
<p>State: {docsearchState}</p>
<button type="button" onClick={openModal}>
Update
</button>
</>
);
};
renderWithProvider(<Comp />);
expect(screen.getByText('State: ready')).toBeInTheDocument();
const updateBtn = await screen.findByRole('button', { name: /Update/ });
act(() => {
fireEvent.click(updateBtn);
});
expect(screen.getByText('State: modal-search')).toBeInTheDocument();
});
it('manages keyboard shortcuts', () => {
renderWithProvider(<StateRender />);
expect(screen.getByText('State: ready')).toBeInTheDocument();
act(() => {
fireEvent.keyDown(document, {
key: 'k',
ctrlKey: true,
});
});
expect(screen.getByText('State: modal-search')).toBeInTheDocument();
act(() => {
fireEvent.keyDown(document, {
code: 'Escape',
});
});
expect(screen.getByText('State: ready')).toBeInTheDocument();
act(() => {
fireEvent.keyDown(document, {
key: '/',
code: 'Slash',
});
});
expect(screen.getByText('State: modal-search')).toBeInTheDocument();
});
it('respects keyboard shortcuts', () => {
renderWithProvider(<StateRender />, {
keyboardShortcuts: {
'Ctrl/Cmd+K': false,
},
});
act(() => {
fireEvent.keyDown(document, {
key: 'k',
ctrlKey: true,
});
});
expect(screen.getByText('State: ready')).toBeInTheDocument();
act(() => {
fireEvent.keyDown(document, {
key: '/',
code: 'Slash',
});
});
expect(screen.getByText('State: modal-search')).toBeInTheDocument();
});
it('sets the theme', () => {
renderWithProvider(<StateRender />, {
theme: 'dark',
});
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
it('respects initial query', () => {
const Comp = (): JSX.Element => {
const { initialQuery } = useDocSearch();
return <p>Query: {initialQuery}</p>;
};
renderWithProvider(<Comp />, {
initialQuery: 'hitComponent',
});
expect(screen.getByText('Query: hitComponent')).toBeInTheDocument();
});
});
describe('useDocSearch', () => {
it('renders hook with default state', () => {
const { result } = renderCustomHook();
expect(result.current).toMatchObject({
docsearchState: 'ready',
isModalActive: false,
isAskAiActive: false,
initialQuery: '',
keyboardShortcuts: {
'Ctrl/Cmd+K': true,
'/': true,
},
searchButtonRef: {
current: null,
},
});
});
it('allows setting current state', () => {
const { result } = renderCustomHook();
act(() => {
result.current.setDocsearchState('modal-askai');
});
expect(result.current.docsearchState).toBe('modal-askai');
act(() => {
result.current.setDocsearchState('modal-search');
});
expect(result.current.docsearchState).toBe('modal-search');
act(() => {
result.current.setDocsearchState('ready');
});
expect(result.current.docsearchState).toBe('ready');
});
it('sets modal state', () => {
const { result } = renderCustomHook();
act(() => {
result.current.openModal();
});
expect(result.current.docsearchState).toBe('modal-search');
act(() => {
result.current.closeModal();
});
expect(result.current.docsearchState).toBe('ready');
});
it('sets askai state', () => {
const { result } = renderCustomHook();
act(() => {
result.current.onAskAiToggle(true);
});
expect(result.current.isAskAiActive).toBe(true);
expect(result.current.isModalActive).toBe(true);
act(() => {
result.current.onAskAiToggle(false);
});
expect(result.current.isAskAiActive).toBe(false);
expect(result.current.isModalActive).toBe(true);
});
it('returns custom keyboard shortcuts', () => {
const { result } = renderCustomHook({
keyboardShortcuts: {
'/': false,
'Ctrl/Cmd+K': false,
},
});
expect(result.current.keyboardShortcuts).toMatchObject({
'Ctrl/Cmd+K': false,
'/': false,
});
});
it('handles initial query', () => {
const { result } = renderCustomHook({
initialQuery: 'hitComponent',
});
expect(result.current.initialQuery).toBe('hitComponent');
});
});
describe('useTheme', () => {
it('sets dark theme', () => {
renderHook(() => useTheme({ theme: 'dark' }));
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
it('sets light theme', () => {
renderHook(() => useTheme({ theme: 'light' }));
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
});
});
describe('useKeyboardShortcuts', () => {
it('uses defaults', () => {
const shortcuts = useKeyboardShortcuts();
expect(shortcuts).toMatchObject({
'Ctrl/Cmd+K': true,
'/': true,
});
});
it('uses custom shortcuts', () => {
const shortcuts = useKeyboardShortcuts({
'/': false,
'Ctrl/Cmd+K': false,
});
expect(shortcuts).toMatchObject({
'Ctrl/Cmd+K': false,
'/': false,
});
});
});
describe('useDocSearchKeyboardEvents', () => {
const onOpen = vi.fn();
const onClose = vi.fn();
const onAskAiToggle = vi.fn();
afterEach(() => {
vi.restoreAllMocks();
});
it('calls to open on keybind', () => {
renderHook(() => {
const searchRef = useRef<HTMLButtonElement | null>(null);
return useDocSearchKeyboardEvents({
isOpen: false,
onOpen,
isAskAiActive: false,
onClose,
searchButtonRef: searchRef,
onAskAiToggle: () => {},
});
});
act(() => {
fireEvent.keyDown(document, {
key: 'k',
ctrlKey: true,
});
});
expect(onOpen).toHaveBeenCalled();
});
it('calls to open on slash', () => {
renderHook(() => {
const searchRef = useRef<HTMLButtonElement | null>(null);
return useDocSearchKeyboardEvents({
isOpen: false,
onOpen,
isAskAiActive: false,
onClose,
searchButtonRef: searchRef,
onAskAiToggle: () => {},
});
});
act(() => {
fireEvent.keyDown(document, {
key: '/',
});
});
expect(onOpen).toHaveBeenCalled();
});
it('calls to close on keybind', () => {
renderHook(() => {
const searchRef = useRef<HTMLButtonElement | null>(null);
return useDocSearchKeyboardEvents({
isOpen: true,
onOpen,
isAskAiActive: false,
onClose,
searchButtonRef: searchRef,
onAskAiToggle: () => {},
});
});
act(() => {
fireEvent.keyDown(document, {
key: 'k',
ctrlKey: true,
});
});
expect(onClose).toHaveBeenCalled();
});
it('closes on escape key', () => {
renderHook(() => {
const searchRef = useRef<HTMLButtonElement | null>(null);
return useDocSearchKeyboardEvents({
isOpen: true,
onOpen,
isAskAiActive: false,
onClose,
searchButtonRef: searchRef,
onAskAiToggle: () => {},
});
});
act(() => {
fireEvent.keyDown(document, {
code: 'Escape',
});
});
expect(onClose).toHaveBeenCalled();
});
it('respects keyboard shortcuts', () => {
renderHook(() => {
const searchRef = useRef<HTMLButtonElement | null>(null);
return useDocSearchKeyboardEvents({
isOpen: false,
onOpen,
isAskAiActive: false,
onClose,
searchButtonRef: searchRef,
onAskAiToggle: () => {},
keyboardShortcuts: {
'/': false,
},
});
});
act(() => {
fireEvent.keyDown(document, {
key: '/',
});
});
expect(onOpen).not.toHaveBeenCalled();
});
it('calls to toggle AskAI', () => {
renderHook(() => {
const searchRef = useRef<HTMLButtonElement | null>(null);
return useDocSearchKeyboardEvents({
isOpen: true,
onOpen,
isAskAiActive: true,
onClose,
searchButtonRef: searchRef,
onAskAiToggle,
});
});
act(() => {
fireEvent.keyDown(document, {
code: 'Escape',
});
});
expect(onAskAiToggle).toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,4 @@
export * from './DocSearch';
export * from './useTheme';
export * from './useDocSearchKeyboardEvents';
export * from './useKeyboardShortcuts';

View file

@ -0,0 +1,69 @@
import React from 'react';
import { DEFAULT_KEYBOARD_SHORTCUTS, type KeyboardShortcuts } from './useKeyboardShortcuts';
export interface UseDocSearchKeyboardEventsProps {
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
onInput?: (event: KeyboardEvent) => void;
searchButtonRef: React.RefObject<HTMLButtonElement | null>;
isAskAiActive: boolean;
onAskAiToggle: (toggle: boolean) => void;
keyboardShortcuts?: KeyboardShortcuts;
}
function isEditingContent(event: KeyboardEvent): boolean {
const element = event.composedPath()[0] as HTMLElement;
const tagName = element.tagName;
return element.isContentEditable || tagName === 'INPUT' || tagName === 'SELECT' || tagName === 'TEXTAREA';
}
export function useDocSearchKeyboardEvents({
isOpen,
isAskAiActive,
onAskAiToggle,
onClose,
onOpen,
onInput,
searchButtonRef,
keyboardShortcuts = DEFAULT_KEYBOARD_SHORTCUTS,
}: UseDocSearchKeyboardEventsProps): void {
React.useEffect(() => {
function onKeyDown(event: KeyboardEvent): void {
if (isOpen && event.code === 'Escape' && isAskAiActive) {
onAskAiToggle(false);
return;
}
const isCmdK =
keyboardShortcuts['Ctrl/Cmd+K'] && event.key?.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey);
const isSlash = keyboardShortcuts['/'] && event.key === '/';
if ((event.code === 'Escape' && isOpen) || isCmdK || (!isEditingContent(event) && isSlash && !isOpen)) {
event.preventDefault();
if (isOpen) {
onClose();
} else if (!document.body.classList.contains('DocSearch--active')) {
onOpen();
}
return;
}
if (searchButtonRef && searchButtonRef.current === document.activeElement && onInput) {
if (/[a-zA-Z0-9]/.test(String.fromCharCode(event.keyCode))) {
onInput(event);
}
}
}
window.addEventListener('keydown', onKeyDown);
return (): void => {
window.removeEventListener('keydown', onKeyDown);
};
}, [isOpen, isAskAiActive, searchButtonRef, keyboardShortcuts, onOpen, onClose, onInput, onAskAiToggle]);
}

View file

@ -0,0 +1,37 @@
export interface KeyboardShortcuts {
/**
* Enable/disable the Ctrl/Cmd+K shortcut to toggle the search modal.
*
* @default true
*/
'Ctrl/Cmd+K'?: boolean;
/**
* Enable/disable the / shortcut to open the search modal.
*
* @default true
*/
'/'?: boolean;
}
/**
* Default keyboard shortcuts configuration for DocSearch.
* These values are used when no keyboardShortcuts prop is provided
* or when specific shortcuts are not configured.
*/
export const DEFAULT_KEYBOARD_SHORTCUTS: Required<KeyboardShortcuts> = {
'Ctrl/Cmd+K': true,
'/': true,
} as const;
/**
* Merges user-provided keyboard shortcuts with defaults.
*
* @param userShortcuts - Optional user configuration.
* @returns Complete keyboard shortcuts configuration with defaults applied.
*/
export function useKeyboardShortcuts(userShortcuts?: KeyboardShortcuts): Required<KeyboardShortcuts> {
return {
...DEFAULT_KEYBOARD_SHORTCUTS,
...userShortcuts,
};
}

View file

@ -0,0 +1,31 @@
import { useEffect } from 'react';
export type DocSearchTheme = 'dark' | 'light';
export interface UseThemeProps {
theme?: DocSearchTheme;
}
export const useTheme = ({ theme }: UseThemeProps): void => {
useEffect(() => {
if (!theme) {
return undefined;
}
const previousTheme = document.documentElement.dataset.theme;
if (theme === previousTheme) {
return undefined;
}
document.documentElement.dataset.theme = theme;
return (): void => {
if (previousTheme === undefined) {
delete document.documentElement.dataset.theme;
} else {
document.documentElement.dataset.theme = previousTheme;
}
};
}, [theme]);
};

View file

@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.declaration",
}

View file

@ -0,0 +1 @@
export * from './dist/esm/useDocSearchKeyboardEvents.js';

View file

@ -0,0 +1 @@
export * from './dist/esm/useTheme.js';

View file

@ -0,0 +1,4 @@
# @docsearch/modal
Keyword Search and AskAI modal package for [DocSearch](http://docsearch.algolia.com/), the best search experience for docs.

View file

@ -0,0 +1,23 @@
export default (api) => {
const isTest = api.env('test');
const targets = {};
if (isTest) {
targets.node = true;
} else {
targets.browsers = ['last 2 versions', 'ie >= 9'];
}
return {
presets: [
'@babel/preset-typescript',
[
'@babel/preset-env',
{
targets,
},
],
],
plugins: [['@babel/plugin-transform-react-jsx']],
};
};

View file

@ -0,0 +1 @@
export * from './dist/esm/DocSearchButton.js';

View file

@ -0,0 +1 @@
export * from './dist/esm/index.js';

View file

@ -0,0 +1 @@
export * from './dist/esm/DocSearchModal.js';

View file

@ -0,0 +1,71 @@
{
"name": "@docsearch/modal",
"description": "DocSearch modal package for keyword search and Ask AI.",
"version": "1.0.0",
"license": "MIT",
"homepage": "https://docsearch.algolia.com",
"repository": {
"type": "git",
"url": "git+https://github.com/algolia/docsearch.git",
"directory": "packages/docsearch-react"
},
"author": {
"name": "Algolia, Inc.",
"url": "https://www.algolia.com"
},
"sideEffects": false,
"files": [
"dist/",
"button.js",
"index.js",
"modal.js"
],
"exports": {
".": "./dist/esm/index.js",
"./button": "./dist/esm/DocSearchButton.js",
"./modal": "./dist/esm/DocSearchModal.js"
},
"source": "src/index.ts",
"types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js",
"main": "dist/umd/index.js",
"umd:main": "dist/umd/index.js",
"unpkg": "dist/umd/index.js",
"jsdelivr": "dist/umd/index.js",
"scripts": {
"build:clean": "rm -rf ./dist",
"build:clean-types": "rm -rf ./dist/esm/types",
"build:types": "tsc -p ./tsconfig.declaration.json --outDir ./dist/esm/types",
"build": "yarn build:clean && yarn build:types && rollup --config --bundleConfigAsCjs && yarn build:clean-types",
"on:change": "yarn build",
"watch": "nodemon --watch src --ext ts,tsx,js,jsx,json --ignore dist/ --ignore node_modules/ --verbose --delay 250ms --exec \"yarn on:change\""
},
"dependencies": {
"@docsearch/react": "4.1.0"
},
"devDependencies": {
"@rollup/plugin-replace": "6.0.2",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.2.0",
"nodemon": "^3.1.0",
"rollup-plugin-dts": "^6.2.1",
"vitest": "3.0.2"
},
"peerDependencies": {
"@docsearch/core": ">= 1.0.0",
"@types/react": ">= 16.8.0 < 20.0.0",
"react": ">= 16.8.0 < 20.0.0",
"react-dom": ">= 16.8.0 < 20.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
}

View file

@ -0,0 +1,73 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import commonjs from '@rollup/plugin-commonjs';
import replace from '@rollup/plugin-replace';
import { dts } from 'rollup-plugin-dts';
import { plugins } from '../../rollup.base.config';
import { getBundleBanner } from '../../scripts/getBundleBanner';
import pkg from './package.json';
const sourcePlugins = [
commonjs(),
...plugins,
replace({
preventAssignment: true,
'process.env.NODE_ENV': JSON.stringify('production'),
}),
];
function sourceOutput(fileName) {
return [
{
globals: {
react: 'React',
'react-dom': 'ReactDOM',
},
file: `dist/umd/${fileName}`,
format: 'umd',
sourcemap: true,
name: pkg.name,
banner: getBundleBanner(pkg),
},
{ dir: 'dist/esm', format: 'es' },
];
}
const externals = ['react', 'react-dom', '@docsearch/core'];
export default [
{
input: 'src/index.ts',
external: externals,
output: sourceOutput('index.js'),
plugins: sourcePlugins,
},
{
input: 'src/DocSearchButton.tsx',
external: externals,
output: sourceOutput('DocSearchButton.js'),
plugins: sourcePlugins,
},
{
input: 'src/DocSearchModal.tsx',
external: externals,
output: sourceOutput('DocSearchModal.js'),
plugins: sourcePlugins,
},
{
input: 'dist/esm/types/index.d.ts',
output: [{ file: 'dist/esm/index.d.ts', format: 'es' }],
plugins: [dts()],
},
{
input: 'dist/esm/types/DocSearchButton.d.ts',
output: [{ file: 'dist/esm/DocSearchButton.d.ts', format: 'es' }],
plugins: [dts()],
},
{
input: 'dist/esm/types/DocSearchModal.d.ts',
output: [{ file: 'dist/esm/DocSearchModal.d.ts', format: 'es' }],
plugins: [dts()],
},
];

View file

@ -0,0 +1,24 @@
import { useDocSearch } from '@docsearch/core';
import { DocSearchButton as Button, type DocSearchButtonProps as ButtonProps } from '@docsearch/react/button';
import type { JSX } from 'react';
import React from 'react';
export type DocSearchButtonProps = Omit<ButtonProps, 'keyboardShortcuts'>;
export function DocSearchButton(props: DocSearchButtonProps): JSX.Element {
const { searchButtonRef, keyboardShortcuts, openModal } = useDocSearch();
return (
<Button
ref={searchButtonRef}
keyboardShortcuts={keyboardShortcuts}
onClick={(evt) => {
if (props.onClick) {
props.onClick(evt);
}
openModal();
}}
{...props}
/>
);
}

View file

@ -0,0 +1,30 @@
import { useDocSearch } from '@docsearch/core';
import type { DocSearchModalProps as ModalProps } from '@docsearch/react/modal';
import { DocSearchModal as Modal } from '@docsearch/react/modal';
import type { JSX } from 'react';
import React from 'react';
import { createPortal } from 'react-dom';
export type DocSearchModalProps = Omit<ModalProps, 'initialScrollY' | 'isAskAiActive' | 'onAskAiToggle'>;
export function DocSearchModal(props: DocSearchModalProps): JSX.Element | null {
const { isModalActive, onAskAiToggle, closeModal, isAskAiActive, initialQuery } = useDocSearch();
const containerElement = React.useMemo(() => props.portalContainer ?? document.body, [props.portalContainer]);
const initialScroll = React.useMemo(() => window.scrollY, []);
const modalProps = React.useMemo(
() => ({
...props,
isAskAiActive,
initialQuery: props.initialQuery ?? initialQuery,
initialScrollY: initialScroll,
onAskAiToggle,
onClose: closeModal,
}),
[props, isAskAiActive, initialQuery, initialScroll, onAskAiToggle, closeModal],
);
return isModalActive ? createPortal(<Modal {...modalProps} />, containerElement) : null;
}

View file

@ -0,0 +1,79 @@
import type { DocSearchProps } from '@docsearch/core';
import { DocSearch } from '@docsearch/core';
import type { RenderResult } from '@testing-library/react';
import { render, screen, cleanup, act, fireEvent } from '@testing-library/react';
import React from 'react';
import { describe, it, expect, afterEach, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { DocSearchButton, type DocSearchButtonProps } from '../DocSearchButton';
const renderComponent = (
props: DocSearchButtonProps = {},
docsearchProps: Omit<DocSearchProps, 'children'> = {},
): RenderResult =>
render(<DocSearchButton {...props} />, {
wrapper({ children }) {
return <DocSearch {...docsearchProps}>{children}</DocSearch>;
},
});
describe('@docsearch/modal', () => {
afterEach(() => {
cleanup();
});
describe('DocSearchButton', () => {
it('renders the button', () => {
renderComponent();
expect(screen.getByRole('button', { name: /Search/ })).toBeInTheDocument();
});
it('renders with translations', () => {
renderComponent({
translations: {
buttonText: 'Test Button Text',
buttonAriaLabel: 'Test Aria',
},
});
expect(screen.getByRole('button', { name: /Test Aria/ })).toBeInTheDocument();
// Look for text because `name` on button points to the aria-label
expect(screen.getByText('Test Button Text')).toBeInTheDocument();
});
it('calls onClick callback', () => {
const clickHandler = vi.fn();
renderComponent({
onClick: clickHandler,
});
const buttonEl = screen.getByRole<HTMLButtonElement>('button', {
name: /Search/,
});
act(() => {
fireEvent.click(buttonEl);
});
expect(clickHandler).toHaveBeenCalled();
});
it('respects keyboard shortcuts', () => {
renderComponent(undefined, {
keyboardShortcuts: {
'Ctrl/Cmd+K': false,
},
});
const buttonEl = screen.getByRole<HTMLButtonElement>('button', {
name: /Search/,
});
expect(buttonEl.getAttribute('aria-keyshortcuts')).toBeNull();
});
});
});

View file

@ -0,0 +1,133 @@
import type { DocSearchProps } from '@docsearch/core';
import { DocSearch } from '@docsearch/core';
import type { RenderResult } from '@testing-library/react';
import { render, screen, cleanup, act, fireEvent } from '@testing-library/react';
import React from 'react';
import { describe, it, expect, afterEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { DocSearchModal, type DocSearchModalProps } from '../DocSearchModal';
const APP_ID = 'test_app';
const API_KEY = 'test_api_key';
const INDEX_NAME = 'test_index';
const DEFAULT_PROPS: DocSearchModalProps = {
appId: APP_ID,
apiKey: API_KEY,
indexName: INDEX_NAME,
transformSearchClient(searchClient) {
return {
...searchClient,
search: () =>
new Promise((resolve) => {
resolve({
results: [
{
hits: [],
hitsPerPage: 0,
nbHits: 0,
nbPages: 0,
page: 0,
processingTimeMS: 0,
exhaustiveNbHits: true,
params: '',
query: '',
},
],
});
}),
};
},
};
const renderComponent = (
props: DocSearchModalProps = DEFAULT_PROPS,
docsearchProps: Omit<DocSearchProps, 'children'> = {},
): RenderResult =>
render(<DocSearchModal {...props} />, {
wrapper({ children }) {
return <DocSearch {...docsearchProps}>{children}</DocSearch>;
},
});
describe('@docsearch/modal', () => {
afterEach(() => {
cleanup();
});
describe('DocSearchModal', () => {
it('renders modal component', () => {
renderComponent();
});
it('opens modal on keyboard shortcut', () => {
renderComponent();
act(() => {
fireEvent.keyDown(document, {
key: 'k',
ctrlKey: true,
});
});
expect(screen.getByText('Search')).toBeInTheDocument();
});
it('closes modal on escape', () => {
renderComponent();
act(() => {
fireEvent.keyDown(document, {
key: 'k',
ctrlKey: true,
});
});
expect(screen.getByText('Search')).toBeInTheDocument();
act(() => {
fireEvent.keyDown(document, {
code: 'Escape',
});
});
expect(screen.queryByText('Search')).not.toBeInTheDocument();
});
it('renders with initial query', () => {
renderComponent({ initialQuery: 'hitComponent', ...DEFAULT_PROPS });
act(() => {
fireEvent.keyDown(document, {
key: 'k',
ctrlKey: true,
});
});
const inputEl = screen.getByPlaceholderText<HTMLInputElement>('Search docs');
expect(inputEl).toBeInTheDocument();
expect(inputEl.value).toBe('hitComponent');
});
it('uses provider initial query', () => {
renderComponent(undefined, {
initialQuery: 'hitComponent',
});
act(() => {
fireEvent.keyDown(document, {
key: 'k',
ctrlKey: true,
});
});
const inputEl = screen.getByPlaceholderText<HTMLInputElement>('Search docs');
expect(inputEl).toBeInTheDocument();
expect(inputEl.value).toBe('hitComponent');
});
});
});

View file

@ -0,0 +1,2 @@
export * from './DocSearchButton';
export * from './DocSearchModal';

View file

@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.declaration",
}

View file

@ -50,6 +50,7 @@
"dependencies": {
"@ai-sdk/react": "^2.0.30",
"@algolia/autocomplete-core": "1.19.2",
"@docsearch/core": "1.0.0",
"@docsearch/css": "4.2.0",
"ai": "^5.0.30",
"algoliasearch": "^5.28.0",

View file

@ -34,34 +34,36 @@ function sourceOutput(fileName) {
];
}
const externalPackages = ['react', 'react-dom', '@docsearch/core'];
export default [
{
input: 'src/index.ts',
external: ['react', 'react-dom'],
external: externalPackages,
output: sourceOutput('index.js'),
plugins: sourcePlugins,
},
{
input: 'src/DocSearchButton.tsx',
external: ['react', 'react-dom'],
external: externalPackages,
output: sourceOutput('DocsearchButton.js'),
plugins: sourcePlugins,
},
{
input: 'src/DocSearchModal.tsx',
external: ['react', 'react-dom'],
external: externalPackages,
output: sourceOutput('DocSearchModal.js'),
plugins: sourcePlugins,
},
{
input: 'src/useDocSearchKeyboardEvents.ts',
external: ['react', 'react-dom'],
external: externalPackages,
output: sourceOutput('useDocSearchKeyboardEvents.js'),
plugins: sourcePlugins,
},
{
input: 'src/useTheme.tsx',
external: ['react', 'react-dom'],
external: externalPackages,
output: sourceOutput('useTheme.js'),
plugins: sourcePlugins,
},

View file

@ -1,4 +1,5 @@
import type { AutocompleteOptions, AutocompleteState } from '@algolia/autocomplete-core';
import { DocSearch as DocSearchProvider, useDocSearch } from '@docsearch/core';
import type { LiteClient, SearchParamsObject } from 'algoliasearch/lite';
import React, { type JSX } from 'react';
import { createPortal } from 'react-dom';
@ -12,8 +13,6 @@ import type {
KeyboardShortcuts,
StoredDocSearchHit,
} from './types';
import { useDocSearchKeyboardEvents } from './useDocSearchKeyboardEvents';
import { useTheme } from './useTheme';
import type { ButtonTranslations, ModalTranslations } from '.';
@ -198,85 +197,43 @@ export interface DocSearchProps {
}
export function DocSearch(props: DocSearchProps): JSX.Element {
const searchButtonRef = React.useRef<HTMLButtonElement>(null);
const [isOpen, setIsOpen] = React.useState(false);
const [initialQuery, setInitialQuery] = React.useState<string | undefined>(props?.initialQuery || undefined);
const [isAskAiActive, setIsAskAiActive] = React.useState(false);
let currentPlaceholder =
props?.translations?.modal?.searchBox?.placeholderText || props?.placeholder || 'Search docs';
// check if the instance is configured to handle ask ai
const canHandleAskAi = Boolean(props?.askAi);
if (canHandleAskAi) {
currentPlaceholder = props?.translations?.modal?.searchBox?.placeholderText || 'Search docs or ask AI a question';
}
if (isAskAiActive) {
currentPlaceholder = props?.translations?.modal?.searchBox?.placeholderTextAskAi || 'Ask another question...';
}
const onAskAiToggle = React.useCallback(
(askAitoggle: boolean) => {
setIsAskAiActive(askAitoggle);
},
[setIsAskAiActive],
return (
<DocSearchProvider {...props}>
<DocSearchInner {...props} />
</DocSearchProvider>
);
}
const onOpen = React.useCallback(() => {
setIsOpen(true);
}, [setIsOpen]);
const onClose = React.useCallback(() => {
setIsOpen(false);
setInitialQuery(props?.initialQuery);
if (isAskAiActive) {
setIsAskAiActive(false);
}
}, [setIsOpen, props.initialQuery, isAskAiActive, setIsAskAiActive]);
const onInput = React.useCallback(
(event: KeyboardEvent) => {
setIsOpen(true);
setInitialQuery(event.key);
},
[setIsOpen, setInitialQuery],
);
useDocSearchKeyboardEvents({
isOpen,
onOpen,
onClose,
onInput,
isAskAiActive,
onAskAiToggle,
export function DocSearchInner(props: DocSearchProps): JSX.Element {
const {
searchButtonRef,
keyboardShortcuts: props.keyboardShortcuts,
});
useTheme({ theme: props.theme });
keyboardShortcuts,
isModalActive,
isAskAiActive,
initialQuery,
onAskAiToggle,
openModal,
closeModal,
} = useDocSearch();
return (
<>
<DocSearchButton
keyboardShortcuts={keyboardShortcuts}
ref={searchButtonRef}
translations={props?.translations?.button}
keyboardShortcuts={props.keyboardShortcuts}
onClick={onOpen}
translations={props.translations?.button}
onClick={openModal}
/>
{isOpen &&
{isModalActive &&
createPortal(
<DocSearchModal
{...props}
placeholder={currentPlaceholder}
initialScrollY={window.scrollY}
initialQuery={initialQuery}
translations={props?.translations?.modal}
isAskAiActive={isAskAiActive}
canHandleAskAi={canHandleAskAi}
onAskAiToggle={onAskAiToggle}
onClose={onClose}
onClose={closeModal}
/>,
props.portalContainer ?? document.body,
)}

View file

@ -1,10 +1,10 @@
import { useTheme } from '@docsearch/core/useTheme';
import React, { useEffect, useState, type JSX } from 'react';
import { getKeyboardShortcuts } from './constants/keyboardShortcuts';
import { ControlKeyIcon, KKeyIcon, MetaKeyIcon } from './icons/MetaKeysIcon';
import { SearchIcon } from './icons/SearchIcon';
import type { DocSearchTheme, KeyboardShortcuts } from './types';
import { useTheme } from './useTheme';
export type ButtonTranslations = Partial<{
buttonText: string;

View file

@ -5,6 +5,7 @@ import {
createAutocomplete,
type AutocompleteState,
} from '@algolia/autocomplete-core';
import { useTheme } from '@docsearch/core/useTheme';
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from 'ai';
import type { SearchResponse } from 'algoliasearch/lite';
import React, { type JSX } from 'react';
@ -24,7 +25,6 @@ import { createStoredConversations, createStoredSearches } from './stored-search
import type { DocSearchHit, DocSearchState, InternalDocSearchHit, StoredAskAiState, StoredDocSearchHit } from './types';
import type { AIMessage, AskAiState } from './types/AskiAi';
import { useSearchClient } from './useSearchClient';
import { useTheme } from './useTheme';
import { useTouchEvents } from './useTouchEvents';
import { useTrapFocus } from './useTrapFocus';
import { groupBy, identity, noop, removeHighlightTags, isModifierEvent, scrollTo as scrollToUtils } from './utils';
@ -276,7 +276,6 @@ const buildQuerySources = async ({
export function DocSearchModal({
appId,
apiKey,
placeholder,
askAi,
maxResultsPerGroup,
theme,
@ -294,12 +293,12 @@ export function DocSearchModal({
insights = false,
onAskAiToggle,
isAskAiActive = false,
canHandleAskAi = false,
recentSearchesLimit = 7,
recentSearchesWithFavoritesLimit = 4,
indices = [],
indexName,
searchParameters,
...props
}: DocSearchModalProps): JSX.Element {
const { footer: footerTranslations, searchBox: searchBoxTranslations, ...screenStateTranslations } = translations;
const [state, setState] = React.useState<DocSearchState<InternalDocSearchHit>>({
@ -312,6 +311,19 @@ export function DocSearchModal({
status: 'idle',
});
// check if the instance is configured to handle ask ai
const canHandleAskAi = Boolean(askAi);
let placeholder = translations?.searchBox?.placeholderText || props.placeholder || 'Search docs';
if (canHandleAskAi) {
placeholder = translations?.searchBox?.placeholderText || 'Search docs or ask AI a question';
}
if (isAskAiActive) {
placeholder = translations?.searchBox?.placeholderTextAskAi || 'Ask another question...';
}
const containerRef = React.useRef<HTMLDivElement | null>(null);
const modalRef = React.useRef<HTMLDivElement | null>(null);
const formElementRef = React.useRef<HTMLDivElement | null>(null);
@ -572,8 +584,8 @@ export function DocSearchModal({
},
insights: Boolean(insights),
navigator,
onStateChange(props) {
setState(props.state);
onStateChange(changes) {
setState(changes.state);
},
getSources({ query, state: sourcesState, setContext, setStatus }) {
if (!query) {

788
yarn.lock

File diff suppressed because it is too large Load diff