Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 90 additions & 10 deletions examples/demo-js/src/main.ts
Original file line number Diff line number Diff line change
@@ -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}`, {

Check warning on line 19 in examples/demo-js/src/main.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

examples/demo-js/src/main.ts#L19

Detected string concatenation with a non-literal variable in a util.format / console.log function.
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<any> }, helpers?: TemplateHelpers) => {
const { html } = helpers || {};
if (!html) return null;
Expand All @@ -26,10 +100,16 @@
},
});

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');
121 changes: 119 additions & 2 deletions packages/docsearch-core/src/DocSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 | null> | JSX.Element | React.ReactNode | null;
theme?: DocSearchTheme;
initialQuery?: string;
Expand All @@ -46,18 +84,60 @@ export interface DocSearchProps {
const Context = React.createContext<DocSearchContext | undefined>(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<DocSearchRef>,
): 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 [initialAskAiMessage, setInitialAskAiMessage] = React.useState<InitialAskAiMessage>();
const [registeredViews, setRegisteredViews] = React.useState(() => new Set<View>());
const isMobile = useIsMobile();
const prevStateRef = React.useRef<DocSearchState>('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');
Expand All @@ -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');
Expand All @@ -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({
Expand Down Expand Up @@ -150,6 +265,8 @@ export function DocSearch({ children, theme, ...props }: DocSearchProps): JSX.El

return <Context.Provider value={value}>{children}</Context.Provider>;
}

export const DocSearch = React.forwardRef(DocSearchInner);
DocSearch.displayName = 'DocSearch';

export function useDocSearch(): DocSearchContext {
Expand Down
77 changes: 67 additions & 10 deletions packages/docsearch-js/src/docsearch.tsx
Original file line number Diff line number Diff line change
@@ -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<DocSearchComponentProps, 'onSidepanelClose' | 'onSidepanelOpen'> & {
container: HTMLElement | string;
environment?: typeof window;
};

function getHTMLElement(value: HTMLElement | string, env: typeof window | undefined): HTMLElement {
if (typeof value !== 'string') return value;
Expand Down Expand Up @@ -43,12 +76,15 @@ function createTemplateFunction<P extends Record<string, unknown>, 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<DocSearchRef>();
let isReady = false;

const props = {
...rest,
ref,
hitComponent: createTemplateFunction(hitComponent),
resultsFooterComponent: createTemplateFunction(resultsFooterComponent),
transformSearchClient: (searchClient: any): any => {
Expand All @@ -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;
},
};
}
Loading