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:
parent
5381e76126
commit
5e9bdfed70
40 changed files with 1975 additions and 439 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
23
examples/demo-react/src/examples/composable.tsx
Normal file
23
examples/demo-react/src/examples/composable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
examples/demo-react/src/examples/dynamic-import-modal.tsx
Normal file
114
examples/demo-react/src/examples/dynamic-import-modal.tsx
Normal 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 />;
|
||||
}
|
||||
3
packages/docsearch-core/README.md
Normal file
3
packages/docsearch-core/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# @docsearch/core
|
||||
|
||||
Core logic and state package for [DocSearch](http://docsearch.algolia.com/), the best search experience for docs.
|
||||
23
packages/docsearch-core/babel.config.mjs
Normal file
23
packages/docsearch-core/babel.config.mjs
Normal 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']],
|
||||
};
|
||||
};
|
||||
1
packages/docsearch-core/index.js
Normal file
1
packages/docsearch-core/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { DocSearch, useDocSearch } from './dist/esm/index.js';
|
||||
69
packages/docsearch-core/package.json
Normal file
69
packages/docsearch-core/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
82
packages/docsearch-core/rollup.config.js
Normal file
82
packages/docsearch-core/rollup.config.js
Normal 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()],
|
||||
},
|
||||
];
|
||||
120
packages/docsearch-core/src/DocSearch.tsx
Normal file
120
packages/docsearch-core/src/DocSearch.tsx
Normal 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;
|
||||
}
|
||||
444
packages/docsearch-core/src/__tests__/DocSearch.test.tsx
Normal file
444
packages/docsearch-core/src/__tests__/DocSearch.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
4
packages/docsearch-core/src/index.ts
Normal file
4
packages/docsearch-core/src/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './DocSearch';
|
||||
export * from './useTheme';
|
||||
export * from './useDocSearchKeyboardEvents';
|
||||
export * from './useKeyboardShortcuts';
|
||||
69
packages/docsearch-core/src/useDocSearchKeyboardEvents.ts
Normal file
69
packages/docsearch-core/src/useDocSearchKeyboardEvents.ts
Normal 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]);
|
||||
}
|
||||
37
packages/docsearch-core/src/useKeyboardShortcuts.ts
Normal file
37
packages/docsearch-core/src/useKeyboardShortcuts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
31
packages/docsearch-core/src/useTheme.ts
Normal file
31
packages/docsearch-core/src/useTheme.ts
Normal 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]);
|
||||
};
|
||||
3
packages/docsearch-core/tsconfig.declaration.json
Normal file
3
packages/docsearch-core/tsconfig.declaration.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "../../tsconfig.declaration",
|
||||
}
|
||||
1
packages/docsearch-core/useDocSearchKeyboardEvents.js
Normal file
1
packages/docsearch-core/useDocSearchKeyboardEvents.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './dist/esm/useDocSearchKeyboardEvents.js';
|
||||
0
packages/docsearch-core/useKeyboardShortcuts.js
Normal file
0
packages/docsearch-core/useKeyboardShortcuts.js
Normal file
1
packages/docsearch-core/useTheme.js
Normal file
1
packages/docsearch-core/useTheme.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './dist/esm/useTheme.js';
|
||||
4
packages/docsearch-modal/README.md
Normal file
4
packages/docsearch-modal/README.md
Normal 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.
|
||||
|
||||
23
packages/docsearch-modal/babel.config.mjs
Normal file
23
packages/docsearch-modal/babel.config.mjs
Normal 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']],
|
||||
};
|
||||
};
|
||||
1
packages/docsearch-modal/button.js
Normal file
1
packages/docsearch-modal/button.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './dist/esm/DocSearchButton.js';
|
||||
1
packages/docsearch-modal/index.js
Normal file
1
packages/docsearch-modal/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './dist/esm/index.js';
|
||||
1
packages/docsearch-modal/modal.js
Normal file
1
packages/docsearch-modal/modal.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './dist/esm/DocSearchModal.js';
|
||||
71
packages/docsearch-modal/package.json
Normal file
71
packages/docsearch-modal/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
73
packages/docsearch-modal/rollup.config.js
Normal file
73
packages/docsearch-modal/rollup.config.js
Normal 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()],
|
||||
},
|
||||
];
|
||||
24
packages/docsearch-modal/src/DocSearchButton.tsx
Normal file
24
packages/docsearch-modal/src/DocSearchButton.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
packages/docsearch-modal/src/DocSearchModal.tsx
Normal file
30
packages/docsearch-modal/src/DocSearchModal.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
133
packages/docsearch-modal/src/__tests__/DocSearchModal.test.tsx
Normal file
133
packages/docsearch-modal/src/__tests__/DocSearchModal.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
2
packages/docsearch-modal/src/index.ts
Normal file
2
packages/docsearch-modal/src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './DocSearchButton';
|
||||
export * from './DocSearchModal';
|
||||
3
packages/docsearch-modal/tsconfig.declaration.json
Normal file
3
packages/docsearch-modal/tsconfig.declaration.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "../../tsconfig.declaration",
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue