Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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
95 changes: 85 additions & 10 deletions packages/docsearch-js/src/docsearch.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,55 @@
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;
/** Returns true if the sidepanel is currently open. */
readonly isSidepanelOpen: boolean;
/** Returns true if sidepanel view is registered (hybrid mode). */
readonly isSidepanelSupported: boolean;
/** 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 not supported). */
openSidepanel(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;
/** Called when the sidepanel opens. */
onSidepanelOpen?: () => void;
/** Called when the sidepanel closes. */
onSidepanelClose?: () => void;
}

export type DocSearchProps = DocSearchCallbacks &
DocSearchComponentProps & {
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 +85,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 +102,41 @@ 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);
},
openSidepanel(initialMessage?: InitialAskAiMessage): void {
ref.current?.openSidepanel(initialMessage);
},
get isReady(): boolean {
return isReady;
},
get isOpen(): boolean {
return ref.current?.isOpen ?? false;
},
get isSidepanelOpen(): boolean {
return ref.current?.isSidepanelOpen ?? false;
},
get isSidepanelSupported(): boolean {
return ref.current?.isSidepanelSupported ?? false;
},
destroy(): void {
unmountComponentAtNode(containerEl);
isReady = false;
},
};
}
2 changes: 1 addition & 1 deletion packages/docsearch-js/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { docsearch as default } from './docsearch';
export type { DocSearchProps, TemplateHelpers } from './docsearch';
export type { DocSearchProps, DocSearchInstance, DocSearchCallbacks, TemplateHelpers } from './docsearch';
10 changes: 7 additions & 3 deletions packages/docsearch-react/src/DocSearch.tsx
Original file line number Diff line number Diff line change
@@ -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 } from '@docsearch/core';
import type { LiteClient, SearchParamsObject } from 'algoliasearch/lite';
import React, { type JSX } from 'react';
import { createPortal } from 'react-dom';
Expand All @@ -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;
Expand Down Expand Up @@ -199,14 +201,16 @@ export interface DocSearchProps {
keyboardShortcuts?: DocSearchModalShortcuts;
}

export function DocSearch(props: DocSearchProps): JSX.Element {
function DocSearchComponent(props: DocSearchProps, ref: React.ForwardedRef<DocSearchRef>): JSX.Element {
return (
<DocSearchProvider {...props}>
<DocSearchProvider {...props} ref={ref}>
<DocSearchInner {...props} />
</DocSearchProvider>
);
}

export const DocSearch = React.forwardRef(DocSearchComponent);

export function DocSearchInner(props: DocSearchProps): JSX.Element {
const {
searchButtonRef,
Expand Down
33 changes: 29 additions & 4 deletions packages/docsearch-react/src/Sidepanel.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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.
*/
Expand Down Expand Up @@ -51,14 +53,37 @@ export type DocSearchSidepanelProps = {
panel?: Omit<SidepanelProps, 'keyboardShortcuts'>;
};

export function DocSearchSidepanel({ keyboardShortcuts, theme, ...props }: DocSearchSidepanelProps): JSX.Element {
function DocSearchSidepanelComponent(
{
keyboardShortcuts,
theme,
onReady,
onOpen,
onClose,
onSidepanelOpen,
onSidepanelClose,
...props
}: DocSearchSidepanelProps,
ref: React.ForwardedRef<DocSearchRef>,
): JSX.Element {
return (
<DocSearch keyboardShortcuts={keyboardShortcuts} theme={theme}>
<DocSearch
keyboardShortcuts={keyboardShortcuts}
theme={theme}
ref={ref}
onReady={onReady}
onOpen={onOpen}
onClose={onClose}
onSidepanelOpen={onSidepanelOpen}
onSidepanelClose={onSidepanelClose}
>
<DocSearchSidepanelComp {...props} />
</DocSearch>
);
}

export const DocSearchSidepanel = React.forwardRef(DocSearchSidepanelComponent);

function DocSearchSidepanelComp({
button: buttonProps = {},
panel: { portalContainer, ...panelProps } = {},
Expand Down
Loading