diff --git a/examples/demo-js/src/main.ts b/examples/demo-js/src/main.ts index 478bd9260..f99e2a01d 100644 --- a/examples/demo-js/src/main.ts +++ b/examples/demo-js/src/main.ts @@ -1,17 +1,91 @@ import type { AutocompleteState } from '@algolia/autocomplete-core'; -import docsearch from '@docsearch/js'; -import type { TemplateHelpers } from '@docsearch/js'; -import sidepanel from '@docsearch/sidepanel-js'; +import type { InitialAskAiMessage } from '@docsearch/core'; +import docsearch, { type DocSearchInstance, type TemplateHelpers } from '@docsearch/js'; +import sidepanel, { type SidepanelInstance } from '@docsearch/sidepanel-js'; import './app.css'; import '@docsearch/css/dist/style.css'; import '@docsearch/css/dist/sidepanel.css'; -docsearch({ +declare global { + interface Window { + docsearch?: DocSearchInstance; + sidepanel?: SidepanelInstance; + } +} + +function logDocSearchState(instance: DocSearchInstance, label: string): void { + // eslint-disable-next-line no-console + console.log(`[demo-js] ${label}`, { + isReady: instance.isReady, + isOpen: instance.isOpen, + }); +} + +function logSidepanelState(instance: SidepanelInstance, label: string): void { + // eslint-disable-next-line no-console + console.log(`[demo-js] ${label}`, { + isReady: instance.isReady, + isOpen: instance.isOpen, + }); +} + +const sidepanelInstance = sidepanel({ + container: '#docsearch-sidepanel', + indexName: 'docsearch', + appId: 'PMZUYBQDAK', + apiKey: '24b09689d5b4223813d9b8e48563c8f6', + assistantId: 'askAIDemo', + onReady: () => { + // eslint-disable-next-line no-console + console.log('[demo-js] sidepanel onReady()'); + }, + onOpen: () => { + // eslint-disable-next-line no-console + console.log('[demo-js] sidepanel onOpen()'); + }, + onClose: () => { + // eslint-disable-next-line no-console + console.log('[demo-js] sidepanel onClose()'); + }, +}); + +window.sidepanel = sidepanelInstance; + +// eslint-disable-next-line no-console +console.log('[demo-js] sidepanel instance exposed on window.sidepanel'); +// eslint-disable-next-line no-console +console.log('[demo-js] sidepanel try:', { + open: 'window.sidepanel?.open()', + openWithMessage: "window.sidepanel?.open({ query: 'Hello from demo-js' })", + close: 'window.sidepanel?.close()', + destroy: 'window.sidepanel?.destroy()', +}); +logSidepanelState(sidepanelInstance, 'sidepanel initial state'); + +const docsearchInstance = docsearch({ container: '#docsearch', indexName: 'docsearch', appId: 'PMZUYBQDAK', apiKey: '24b09689d5b4223813d9b8e48563c8f6', + askAi: 'askAIDemo', + interceptAskAiEvent: (initialMessage: InitialAskAiMessage) => { + docsearchInstance.close(); + sidepanelInstance.open(initialMessage); + return true; + }, + onReady: () => { + // eslint-disable-next-line no-console + console.log('[demo-js] docsearch onReady()'); + }, + onOpen: () => { + // eslint-disable-next-line no-console + console.log('[demo-js] docsearch onOpen()'); + }, + onClose: () => { + // eslint-disable-next-line no-console + console.log('[demo-js] docsearch onClose()'); + }, resultsFooterComponent: ({ state }: { state: AutocompleteState }, helpers?: TemplateHelpers) => { const { html } = helpers || {}; if (!html) return null; @@ -26,10 +100,16 @@ docsearch({ }, }); -sidepanel({ - container: '#docsearch-sidepanel', - indexName: 'docsearch', - appId: 'PMZUYBQDAK', - apiKey: '24b09689d5b4223813d9b8e48563c8f6', - assistantId: 'askAIDemo', +// Expose instance +window.docsearch = docsearchInstance; + +// eslint-disable-next-line no-console +console.log('[demo-js] docsearch instance exposed on window.docsearch'); +// eslint-disable-next-line no-console +console.log('[demo-js] docsearch try:', { + open: 'window.docsearch?.open()', + close: 'window.docsearch?.close()', + openAskAi: "window.docsearch?.openAskAi({ query: 'Hello from demo-js' })", + destroy: 'window.docsearch?.destroy()', }); +logDocSearchState(docsearchInstance, 'docsearch initial state'); diff --git a/packages/docsearch-core/src/DocSearch.tsx b/packages/docsearch-core/src/DocSearch.tsx index fbfa0f2ba..9fe8657c3 100644 --- a/packages/docsearch-core/src/DocSearch.tsx +++ b/packages/docsearch-core/src/DocSearch.tsx @@ -20,6 +20,28 @@ export type InitialAskAiMessage = { export type OnAskAiToggle = (active: boolean, initialMessage?: InitialAskAiMessage) => void; +/** + * Imperative handle exposed by the DocSearch provider for programmatic control. + */ +export interface DocSearchRef { + /** Opens the search modal. */ + open: () => void; + /** Closes the search modal. */ + close: () => void; + /** Opens Ask AI mode (sidepanel if available, otherwise modal). */ + openAskAi: (initialMessage?: InitialAskAiMessage) => void; + /** Opens the sidepanel directly (no-op if sidepanel view not registered). */ + openSidepanel: (initialMessage?: InitialAskAiMessage) => void; + /** Returns true once the component is mounted and ready. */ + readonly isReady: boolean; + /** Returns true if the modal is currently open. */ + readonly isOpen: boolean; + /** Returns true if the sidepanel is currently open. */ + readonly isSidepanelOpen: boolean; + /** Returns true if sidepanel view is registered (hybrid mode). */ + readonly isSidepanelSupported: boolean; +} + export interface DocSearchContext { docsearchState: DocSearchState; setDocsearchState: (newState: DocSearchState) => void; @@ -36,7 +58,23 @@ export interface DocSearchContext { isHybridModeSupported: boolean; } -export interface DocSearchProps { +/** + * Lifecycle callbacks for DocSearch. + */ +export interface DocSearchCallbacks { + /** Called once DocSearch is mounted and ready for interaction. */ + onReady?: () => void; + /** Called when the modal opens. */ + onOpen?: () => void; + /** Called when the modal closes. */ + onClose?: () => void; + /** Called when the sidepanel opens. */ + onSidepanelOpen?: () => void; + /** Called when the sidepanel closes. */ + onSidepanelClose?: () => void; +} + +export interface DocSearchProps extends DocSearchCallbacks { children: Array | JSX.Element | React.ReactNode | null; theme?: DocSearchTheme; initialQuery?: string; @@ -46,7 +84,10 @@ export interface DocSearchProps { const Context = React.createContext(undefined); Context.displayName = 'DocSearchContext'; -export function DocSearch({ children, theme, ...props }: DocSearchProps): JSX.Element { +function DocSearchInner( + { children, theme, onReady, onOpen, onClose, onSidepanelOpen, onSidepanelClose, ...props }: DocSearchProps, + ref: React.ForwardedRef, +): JSX.Element { const [docsearchState, setDocsearchState] = React.useState('ready'); const [initialQuery, setInitialQuery] = React.useState(props.initialQuery || ''); const searchButtonRef = React.useRef(null); @@ -54,10 +95,49 @@ export function DocSearch({ children, theme, ...props }: DocSearchProps): JSX.El const [initialAskAiMessage, setInitialAskAiMessage] = React.useState(); const [registeredViews, setRegisteredViews] = React.useState(() => new Set()); const isMobile = useIsMobile(); + const prevStateRef = React.useRef('ready'); const isModalActive = ['modal-search', 'modal-askai'].includes(docsearchState); const isAskAiActive = docsearchState === 'modal-askai'; const isHybridModeSupported = registeredViews.has('sidepanel'); + const isSidepanelOpen = docsearchState === 'sidepanel'; + + // Call onReady on mount + React.useEffect(() => { + onReady?.(); + }, [onReady]); + + // Track state changes for lifecycle callbacks + React.useEffect(() => { + const prevState = prevStateRef.current; + const currentState = docsearchState; + + // Modal opened + if ( + (currentState === 'modal-search' || currentState === 'modal-askai') && + prevState !== 'modal-search' && + prevState !== 'modal-askai' + ) { + onOpen?.(); + } + + // Modal closed + if (currentState === 'ready' && (prevState === 'modal-search' || prevState === 'modal-askai')) { + onClose?.(); + } + + // Sidepanel opened + if (currentState === 'sidepanel' && prevState !== 'sidepanel') { + onSidepanelOpen?.(); + } + + // Sidepanel closed + if (currentState !== 'sidepanel' && prevState === 'sidepanel') { + onSidepanelClose?.(); + } + + prevStateRef.current = currentState; + }, [docsearchState, onOpen, onClose, onSidepanelOpen, onSidepanelClose]); const openModal = React.useCallback((): void => { setDocsearchState('modal-search'); @@ -82,6 +162,17 @@ export function DocSearch({ children, theme, ...props }: DocSearchProps): JSX.El [setDocsearchState, isMobile, isHybridModeSupported], ); + const openSidepanel = React.useCallback( + (initialMessage?: InitialAskAiMessage): void => { + // Guard: no-op if sidepanel view hasn't been registered + if (!registeredViews.has('sidepanel')) return; + + setInitialAskAiMessage(initialMessage); + setDocsearchState('sidepanel'); + }, + [setDocsearchState, registeredViews], + ); + const onInput = React.useCallback( (event: KeyboardEvent): void => { setDocsearchState('modal-search'); @@ -103,6 +194,30 @@ export function DocSearch({ children, theme, ...props }: DocSearchProps): JSX.El [registeredViews], ); + // Expose imperative handle for programmatic control + React.useImperativeHandle( + ref, + () => ({ + open: openModal, + close: closeModal, + openAskAi: (initialMessage?: InitialAskAiMessage): void => onAskAiToggle(true, initialMessage), + openSidepanel, + get isReady(): boolean { + return true; + }, + get isOpen(): boolean { + return isModalActive; + }, + get isSidepanelOpen(): boolean { + return isSidepanelOpen; + }, + get isSidepanelSupported(): boolean { + return isHybridModeSupported; + }, + }), + [openModal, closeModal, onAskAiToggle, openSidepanel, isModalActive, isSidepanelOpen, isHybridModeSupported], + ); + useTheme({ theme }); useDocSearchKeyboardEvents({ @@ -150,6 +265,8 @@ export function DocSearch({ children, theme, ...props }: DocSearchProps): JSX.El return {children}; } + +export const DocSearch = React.forwardRef(DocSearchInner); DocSearch.displayName = 'DocSearch'; export function useDocSearch(): DocSearchContext { diff --git a/packages/docsearch-js/src/docsearch.tsx b/packages/docsearch-js/src/docsearch.tsx index a4afb000c..0902ede99 100644 --- a/packages/docsearch-js/src/docsearch.tsx +++ b/packages/docsearch-js/src/docsearch.tsx @@ -1,13 +1,46 @@ -import type { DocSearchProps as DocSearchComponentProps } from '@docsearch/react'; +import type { InitialAskAiMessage } from '@docsearch/core'; +import type { DocSearchProps as DocSearchComponentProps, DocSearchRef } from '@docsearch/react'; import { DocSearch, version as docSearchVersion } from '@docsearch/react'; import htm from 'htm'; import type { JSX } from 'preact'; -import { createElement, render, isValidElement, unmountComponentAtNode } from 'preact/compat'; +import { createElement, render, isValidElement, unmountComponentAtNode, createRef } from 'preact/compat'; -export type DocSearchProps = DocSearchComponentProps & { - container: HTMLElement | string; - environment?: typeof window; -}; +/** + * Instance returned by docsearch() for programmatic control. + */ +export interface DocSearchInstance { + /** Returns true once the component is mounted and ready. */ + readonly isReady: boolean; + /** Returns true if the modal is currently open. */ + readonly isOpen: boolean; + /** Opens the search modal. */ + open(): void; + /** Closes the search modal. */ + close(): void; + /** Opens Ask AI mode (modal). */ + openAskAi(initialMessage?: InitialAskAiMessage): void; + /** Unmounts the DocSearch component and cleans up. */ + destroy(): void; +} + +/** + * Lifecycle callbacks for the DocSearch instance. + */ +export interface DocSearchCallbacks { + /** Called once DocSearch is mounted and ready for interaction. */ + onReady?: () => void; + /** Called when the modal opens. */ + onOpen?: () => void; + /** Called when the modal closes. */ + onClose?: () => void; + interceptAskAiEvent?: (initialMessage: InitialAskAiMessage) => boolean | void; +} + +export type DocSearchProps = DocSearchCallbacks & + Omit & { + container: HTMLElement | string; + environment?: typeof window; + }; function getHTMLElement(value: HTMLElement | string, env: typeof window | undefined): HTMLElement { if (typeof value !== 'string') return value; @@ -43,12 +76,15 @@ function createTemplateFunction

, R = JSX.Eleme }; } -export function docsearch(allProps: DocSearchProps): () => void { +export function docsearch(allProps: DocSearchProps): DocSearchInstance { const { container, environment, transformSearchClient, hitComponent, resultsFooterComponent, ...rest } = allProps; const containerEl = getHTMLElement(container, environment || (typeof window !== 'undefined' ? window : undefined)); + const ref = createRef(); + let isReady = false; const props = { ...rest, + ref, hitComponent: createTemplateFunction(hitComponent), resultsFooterComponent: createTemplateFunction(resultsFooterComponent), transformSearchClient: (searchClient: any): any => { @@ -57,11 +93,32 @@ export function docsearch(allProps: DocSearchProps): () => void { } return typeof transformSearchClient === 'function' ? transformSearchClient(searchClient) : searchClient; }, - } satisfies DocSearchComponentProps; + } satisfies DocSearchComponentProps & { ref: typeof ref }; render(createElement(DocSearch, props), containerEl); - return () => { - unmountComponentAtNode(containerEl); + // Mark as ready after render completes + isReady = true; + + return { + open(): void { + ref.current?.open(); + }, + close(): void { + ref.current?.close(); + }, + openAskAi(initialMessage?: InitialAskAiMessage): void { + ref.current?.openAskAi(initialMessage); + }, + get isReady(): boolean { + return isReady; + }, + get isOpen(): boolean { + return ref.current?.isOpen ?? false; + }, + destroy(): void { + unmountComponentAtNode(containerEl); + isReady = false; + }, }; } diff --git a/packages/docsearch-js/src/index.ts b/packages/docsearch-js/src/index.ts index 64222787e..e2800792d 100644 --- a/packages/docsearch-js/src/index.ts +++ b/packages/docsearch-js/src/index.ts @@ -1,2 +1,2 @@ export { docsearch as default } from './docsearch'; -export type { DocSearchProps, TemplateHelpers } from './docsearch'; +export type { DocSearchProps, DocSearchInstance, DocSearchCallbacks, TemplateHelpers } from './docsearch'; diff --git a/packages/docsearch-react/src/DocSearch.tsx b/packages/docsearch-react/src/DocSearch.tsx index 7f8835e3d..ef52587c9 100644 --- a/packages/docsearch-react/src/DocSearch.tsx +++ b/packages/docsearch-react/src/DocSearch.tsx @@ -1,6 +1,6 @@ import type { AutocompleteOptions, AutocompleteState } from '@algolia/autocomplete-core'; import { DocSearch as DocSearchProvider, useDocSearch } from '@docsearch/core'; -import type { DocSearchModalShortcuts } from '@docsearch/core'; +import type { DocSearchModalShortcuts, DocSearchRef, InitialAskAiMessage } from '@docsearch/core'; import type { LiteClient, SearchParamsObject } from 'algoliasearch/lite'; import React, { type JSX } from 'react'; import { createPortal } from 'react-dom'; @@ -11,6 +11,8 @@ import type { DocSearchHit, DocSearchTheme, InternalDocSearchHit, StoredDocSearc import type { ButtonTranslations, ModalTranslations } from '.'; +export type { DocSearchRef } from '@docsearch/core'; + export type DocSearchTranslations = Partial<{ button: ButtonTranslations; modal: ModalTranslations; @@ -96,6 +98,19 @@ export interface DocSearchProps { * Configuration or assistant id to enable ask ai mode. Pass a string assistant id or a full config object. */ askAi?: DocSearchAskAi | string; + /** + * Intercept Ask AI requests (e.g. Submitting a prompt or selecting a suggested question). + * + * Return `true` to prevent the default modal Ask AI flow (no toggle, no sendMessage). + * Useful to route Ask AI into a different UI (e.g. `@docsearch/sidepanel-js`) without flicker. + */ + /** + * Intercept Ask AI events (prompt submit, suggested question selection, etc). + * + * Return `true` to prevent *all* default Ask AI behavior that would normally follow + * (no toggle, no sendMessage, no internal Ask AI state updates). + */ + interceptAskAiEvent?: (initialMessage: InitialAskAiMessage) => boolean | void; /** * Theme overrides applied to the modal and related components. */ @@ -201,14 +216,16 @@ export interface DocSearchProps { keyboardShortcuts?: DocSearchModalShortcuts; } -export function DocSearch(props: DocSearchProps): JSX.Element { +function DocSearchComponent(props: DocSearchProps, ref: React.ForwardedRef): JSX.Element { return ( - + ); } +export const DocSearch = React.forwardRef(DocSearchComponent); + export function DocSearchInner(props: DocSearchProps): JSX.Element { const { searchButtonRef, diff --git a/packages/docsearch-react/src/DocSearchModal.tsx b/packages/docsearch-react/src/DocSearchModal.tsx index d532b276f..490c00a62 100644 --- a/packages/docsearch-react/src/DocSearchModal.tsx +++ b/packages/docsearch-react/src/DocSearchModal.tsx @@ -4,7 +4,7 @@ import { createAutocomplete, type AutocompleteState, } from '@algolia/autocomplete-core'; -import type { OnAskAiToggle } from '@docsearch/core'; +import type { InitialAskAiMessage, OnAskAiToggle } from '@docsearch/core'; import { useTheme } from '@docsearch/core/useTheme'; import type { ChatRequestOptions } from 'ai'; import type { SearchResponse } from 'algoliasearch/lite'; @@ -50,6 +50,7 @@ export type ModalTranslations = Partial<{ export type DocSearchModalProps = DocSearchProps & { initialScrollY: number; onAskAiToggle: OnAskAiToggle; + interceptAskAiEvent?: (initialMessage: InitialAskAiMessage) => boolean | void; onClose?: () => void; isAskAiActive?: boolean; translations?: ModalTranslations; @@ -301,6 +302,7 @@ export function DocSearchModal({ getMissingResultsUrl, insights = false, onAskAiToggle, + interceptAskAiEvent, isAskAiActive = false, recentSearchesLimit = 7, recentSearchesWithFavoritesLimit = 4, @@ -506,6 +508,21 @@ export function DocSearchModal({ const handleSelectAskAiQuestion = React.useCallback( (toggle: boolean, query: string, suggestedQuestion: SuggestedQuestionHit | undefined = undefined) => { + if (toggle) { + const initialMessage: InitialAskAiMessage = { + query, + suggestedQuestionId: suggestedQuestion?.objectID, + }; + + if (interceptAskAiEvent?.(initialMessage)) { + // Consumer handled it. Avoid *all* default Ask AI behavior. + if (autocompleteRef.current) { + autocompleteRef.current.setQuery(''); + } + return; + } + } + if (toggle && askAiState === 'new-conversation') { setAskAiState('initial'); } @@ -558,7 +575,7 @@ export function DocSearchModal({ autocompleteRef.current.setQuery(''); } }, - [onAskAiToggle, sendMessage, askAiState, setAskAiState, isHybridModeSupported], + [onAskAiToggle, interceptAskAiEvent, sendMessage, askAiState, setAskAiState, isHybridModeSupported], ); // feedback handler @@ -900,10 +917,20 @@ export function DocSearchModal({ // if the item is askAI and the anchor is stored if (item.anchor === 'stored' && 'messages' in item) { setMessages(item.messages as any); - onAskAiToggle(true, { + const initialMessage: InitialAskAiMessage = { query: item.query, messageId: (item.messages as StoredAskAiMessage[])[0].id, - }); + }; + + if (interceptAskAiEvent?.(initialMessage)) { + if (autocompleteRef.current) { + autocompleteRef.current.setQuery(''); + } + event.preventDefault(); + return; + } + + onAskAiToggle(true, initialMessage); } else { handleSelectAskAiQuestion(true, item.query); } diff --git a/packages/docsearch-react/src/Sidepanel.tsx b/packages/docsearch-react/src/Sidepanel.tsx index 5fbe81ca3..8ac033190 100644 --- a/packages/docsearch-react/src/Sidepanel.tsx +++ b/packages/docsearch-react/src/Sidepanel.tsx @@ -1,5 +1,5 @@ import { DocSearch, useDocSearch } from '@docsearch/core'; -import type { DocSearchTheme, SidepanelShortcuts } from '@docsearch/core'; +import type { DocSearchCallbacks, DocSearchRef, DocSearchTheme, SidepanelShortcuts } from '@docsearch/core'; import type { JSX } from 'react'; import React from 'react'; import { createPortal } from 'react-dom'; @@ -8,7 +8,9 @@ import type { AskAiSearchParameters } from './DocSearch'; import type { SidepanelButtonProps, SidepanelProps } from './Sidepanel/index'; import { SidepanelButton, Sidepanel } from './Sidepanel/index'; -export type DocSearchSidepanelProps = { +export type { DocSearchRef, DocSearchCallbacks } from '@docsearch/core'; + +export type DocSearchSidepanelProps = DocSearchCallbacks & { /** * The assistant ID to use for the ask AI feature. */ @@ -51,20 +53,43 @@ export type DocSearchSidepanelProps = { panel?: Omit; }; -export function DocSearchSidepanel({ keyboardShortcuts, theme, ...props }: DocSearchSidepanelProps): JSX.Element { +function DocSearchSidepanelComponent( + { + keyboardShortcuts, + theme, + onReady, + onOpen, + onClose, + onSidepanelOpen, + onSidepanelClose, + ...props + }: DocSearchSidepanelProps, + ref: React.ForwardedRef, +): JSX.Element { return ( - + ); } +export const DocSearchSidepanel = React.forwardRef(DocSearchSidepanelComponent); + function DocSearchSidepanelComp({ button: buttonProps = {}, panel: { portalContainer, ...panelProps } = {}, ...rootProps }: DocSearchSidepanelProps): JSX.Element { - const { docsearchState, setDocsearchState, keyboardShortcuts, registerView } = useDocSearch(); + const { docsearchState, setDocsearchState, keyboardShortcuts, registerView, initialAskAiMessage } = useDocSearch(); const toggleSidepanelState = React.useCallback(() => { setDocsearchState(docsearchState === 'sidepanel' ? 'ready' : 'sidepanel'); @@ -94,6 +119,7 @@ function DocSearchSidepanelComp({ {buttonProps.variant === 'inline' ? ButtonComp : createPortal(ButtonComp, containerElement)} {createPortal( void; + /** Closes the sidepanel. */ + close: () => void; + /** Returns true once the component is mounted and ready. */ + readonly isReady: boolean; + /** Returns true if the sidepanel is currently open. */ + readonly isOpen: boolean; +} + export type SidepanelTranslations = Partial<{ /** * Translation texts for the Sidepanel header. @@ -115,26 +129,29 @@ type Props = Omit & initialMessage?: InitialAskAiMessage; }; -export const Sidepanel = ({ - isOpen = false, - onOpen, - onClose, - assistantId, - apiKey, - appId, - indexName, - variant = 'floating', - searchParameters, - pushSelector, - width, - expandedWidth, - suggestedQuestions: suggestedQuestionsEnabled = false, - translations = {}, - keyboardShortcuts, - side = 'right', - initialMessage, - useStagingEnv = false, -}: Props): JSX.Element => { +function SidepanelInner( + { + isOpen = false, + onOpen, + onClose, + assistantId, + apiKey, + appId, + indexName, + variant = 'floating', + searchParameters, + pushSelector, + width, + expandedWidth, + suggestedQuestions: suggestedQuestionsEnabled = false, + translations = {}, + keyboardShortcuts, + side = 'right', + initialMessage, + useStagingEnv = false, + }: Props, + ref: React.ForwardedRef, +): JSX.Element { const [isExpanded, setIsExpanded] = React.useState(false); const [sidepanelState, setSidepanelState] = React.useState('new-conversation'); const [stoppedStreaming, setStoppedStreaming] = React.useState(false); @@ -242,6 +259,22 @@ export const Sidepanel = ({ keyboardShortcuts, }); + // Expose imperative handle for programmatic control + React.useImperativeHandle( + ref, + () => ({ + open: onOpen, + close: onClose, + get isReady(): boolean { + return true; + }, + get isOpen(): boolean { + return isOpen; + }, + }), + [onOpen, onClose, isOpen], + ); + React.useEffect(() => { if (prevStatus.current === 'streaming' && status === 'ready') { if (stoppedStreaming && messages.at(-1)) { @@ -372,4 +405,6 @@ export const Sidepanel = ({ ); -}; +} + +export const Sidepanel = React.forwardRef(SidepanelInner); diff --git a/packages/docsearch-react/src/__tests__/imperative.test.tsx b/packages/docsearch-react/src/__tests__/imperative.test.tsx new file mode 100644 index 000000000..86911ccc2 --- /dev/null +++ b/packages/docsearch-react/src/__tests__/imperative.test.tsx @@ -0,0 +1,141 @@ +import type { DocSearchCallbacks } from '@docsearch/core'; +import { render, act, cleanup } from '@testing-library/react'; +import React, { type JSX, useRef, type RefObject } from 'react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; + +import '@testing-library/jest-dom/vitest'; + +import { type DocSearchProps, DocSearch as DocSearchComponent, type DocSearchRef } from '../DocSearch'; + +type TestDocSearchProps = DocSearchCallbacks & Partial & { refObj?: RefObject }; + +function DocSearch(props: TestDocSearchProps): JSX.Element { + const internalRef = useRef(null); + const ref = props.refObj ?? internalRef; + + return ; +} + +describe('imperative handle', () => { + afterEach(() => { + cleanup(); + }); + + describe('DocSearchRef', () => { + it('exposes isReady as true after mount', () => { + const ref = React.createRef(); + + render(); + + expect(ref.current).not.toBeNull(); + expect(ref.current?.isReady).toBe(true); + }); + + it('exposes isOpen as false initially', () => { + const ref = React.createRef(); + + render(); + + expect(ref.current?.isOpen).toBe(false); + }); + + it('opens modal via open() method', async () => { + const ref = React.createRef(); + + render(); + + expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument(); + + await act(() => { + ref.current?.open(); + }); + + expect(document.querySelector('.DocSearch-Modal')).toBeInTheDocument(); + expect(ref.current?.isOpen).toBe(true); + }); + + it('closes modal via close() method', async () => { + const ref = React.createRef(); + + render(); + + await act(() => { + ref.current?.open(); + }); + + expect(document.querySelector('.DocSearch-Modal')).toBeInTheDocument(); + + await act(() => { + ref.current?.close(); + }); + + expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument(); + expect(ref.current?.isOpen).toBe(false); + }); + }); + + describe('lifecycle callbacks', () => { + it('calls onReady after mount', () => { + const onReady = vi.fn(); + + render(); + + expect(onReady).toHaveBeenCalledTimes(1); + }); + + it('calls onOpen when modal opens', async () => { + const onOpen = vi.fn(); + const ref = React.createRef(); + + render(); + + expect(onOpen).not.toHaveBeenCalled(); + + await act(() => { + ref.current?.open(); + }); + + expect(onOpen).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when modal closes', async () => { + const onClose = vi.fn(); + const ref = React.createRef(); + + render(); + + await act(() => { + ref.current?.open(); + }); + + expect(onClose).not.toHaveBeenCalled(); + + await act(() => { + ref.current?.close(); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not call onOpen when already open', async () => { + const onOpen = vi.fn(); + const ref = React.createRef(); + + render(); + + await act(() => { + ref.current?.open(); + }); + + expect(onOpen).toHaveBeenCalledTimes(1); + + // Try opening again while already open + await act(() => { + ref.current?.open(); + }); + + // Should still be 1 - no duplicate callback + expect(onOpen).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/docsearch-sidepanel-js/src/index.ts b/packages/docsearch-sidepanel-js/src/index.ts index ba2a4979b..311ce8861 100644 --- a/packages/docsearch-sidepanel-js/src/index.ts +++ b/packages/docsearch-sidepanel-js/src/index.ts @@ -1,2 +1,2 @@ export { sidepanel as default } from './sidepanel'; -export type { SidepanelProps } from './sidepanel'; +export type { SidepanelProps, SidepanelInstance, SidepanelCallbacks } from './sidepanel'; diff --git a/packages/docsearch-sidepanel-js/src/sidepanel.tsx b/packages/docsearch-sidepanel-js/src/sidepanel.tsx index 04d9ede78..a966f146d 100644 --- a/packages/docsearch-sidepanel-js/src/sidepanel.tsx +++ b/packages/docsearch-sidepanel-js/src/sidepanel.tsx @@ -1,11 +1,41 @@ -import type { DocSearchSidepanelProps } from '@docsearch/react/sidepanel'; +import type { InitialAskAiMessage } from '@docsearch/core'; +import type { DocSearchRef, DocSearchSidepanelProps } from '@docsearch/react/sidepanel'; import { DocSearchSidepanel } from '@docsearch/react/sidepanel'; -import { render, createElement, unmountComponentAtNode } from 'preact/compat'; +import { render, createElement, unmountComponentAtNode, createRef } from 'preact/compat'; -export type SidepanelProps = DocSearchSidepanelProps & { - container: HTMLElement | string; - environment?: typeof window; -}; +/** + * Instance returned by sidepanel() for programmatic control. + */ +export interface SidepanelInstance { + /** Returns true once the component is mounted and ready. */ + readonly isReady: boolean; + /** Returns true if the sidepanel is currently open. */ + readonly isOpen: boolean; + /** Opens the sidepanel, optionally with an initial message. */ + open(initialMessage?: InitialAskAiMessage): void; + /** Closes the sidepanel. */ + close(): void; + /** Unmounts the Sidepanel component and cleans up. */ + destroy(): void; +} + +/** + * Lifecycle callbacks for the Sidepanel instance. + */ +export interface SidepanelCallbacks { + /** Called once Sidepanel is mounted and ready for interaction. */ + onReady?: () => void; + /** Called when the sidepanel opens. */ + onOpen?: () => void; + /** Called when the sidepanel closes. */ + onClose?: () => void; +} + +export type SidepanelProps = DocSearchSidepanelProps & + SidepanelCallbacks & { + container: HTMLElement | string; + environment?: typeof window; + }; function getHTMLElement(value: HTMLElement | string, env: typeof window | undefined): HTMLElement { if (typeof value !== 'string') return value; @@ -15,13 +45,42 @@ function getHTMLElement(value: HTMLElement | string, env: typeof window | undefi return el; } -export function sidepanel(props: SidepanelProps): () => void { - const { container, environment, ...sidepanelProps } = props; +export function sidepanel(props: SidepanelProps): SidepanelInstance { + const { container, environment, onReady, onOpen, onClose, ...sidepanelProps } = props; const containerEl = getHTMLElement(container, environment || (typeof window !== 'undefined' ? window : undefined)); + const ref = createRef(); + let isReady = false; + + // Map sidepanel-specific callbacks to core callbacks + const componentProps = { + ...sidepanelProps, + ref, + onReady: (): void => { + isReady = true; + onReady?.(); + }, + onSidepanelOpen: onOpen, + onSidepanelClose: onClose, + }; - render(createElement(DocSearchSidepanel, sidepanelProps), containerEl); + render(createElement(DocSearchSidepanel, componentProps), containerEl); - return () => { - unmountComponentAtNode(containerEl); + return { + open(initialMessage?: InitialAskAiMessage): void { + ref.current?.openSidepanel(initialMessage); + }, + close(): void { + ref.current?.close(); + }, + get isReady(): boolean { + return isReady; + }, + get isOpen(): boolean { + return ref.current?.isSidepanelOpen ?? false; + }, + destroy(): void { + unmountComponentAtNode(containerEl); + isReady = false; + }, }; }