From b28bc8490453535c964db26a022021399bd08d6b Mon Sep 17 00:00:00 2001 From: Paul Jankowski <8bittitan@gmail.com> Date: Wed, 5 Aug 2026 12:07:54 -0400 Subject: [PATCH] feat(v5): Add new footerAction prop (#2952) * feat(v5): Add new footerAction prop * Resolve PR comments --- .changeset/sixty-lights-try.md | 14 +++ bun.lock | 22 ++-- examples/demo-js/package.json | 6 +- examples/demo-js/src/main.ts | 10 +- .../demo-react/src/examples/basic-askai.tsx | 5 + packages/docsearch-css/src/modal.css | 18 ++- .../src/__tests__/createDocSearch.test.tsx | 103 ++++++++++++++++++ packages/docsearch-js/src/createDocSearch.tsx | 94 +++++++++++++--- packages/docsearch-js/src/docsearch.ts | 7 +- packages/docsearch-js/src/docsearchAi.tsx | 6 +- .../docsearch-js/src/docsearchComponent.tsx | 6 +- packages/docsearch-js/src/index.ts | 7 +- packages/docsearch-react/src/DocSearch.tsx | 49 ++++----- .../src/DocSearchAskAiModal.tsx | 2 + .../docsearch-react/src/DocSearchModal.tsx | 5 +- packages/docsearch-react/src/Footer.tsx | 18 ++- .../src/__tests__/api.test.tsx | 82 ++++++++++++++ .../docs/packages/js/api-reference.mdx | 32 ++++-- packages/website/docs/packages/modal/api.mdx | 10 +- .../docs/packages/react/api-reference.mdx | 31 +++++- 20 files changed, 428 insertions(+), 99 deletions(-) create mode 100644 .changeset/sixty-lights-try.md create mode 100644 packages/docsearch-js/src/__tests__/createDocSearch.test.tsx diff --git a/.changeset/sixty-lights-try.md b/.changeset/sixty-lights-try.md new file mode 100644 index 00000000..36511e25 --- /dev/null +++ b/.changeset/sixty-lights-try.md @@ -0,0 +1,14 @@ +--- +"@docsearch/react": patch +"@docsearch/css": patch +"@docsearch/js": patch +--- + +feat(v5): add customizable footer action + +- New `footerAction` prop renders a custom action in the modal footer, + before the Algolia logo, inside `.DocSearch-Footer-Action` +- `@docsearch/js` supports `footerAction` via template patterns (html helper, + JSX, or function-based) +- Fixes typing differences between `@docsearch/react` and `@docsearch/js` +- Restyle the footer with a `.DocSearch-Footer-Actions` wrapper diff --git a/bun.lock b/bun.lock index 99dc9523..a7632619 100644 --- a/bun.lock +++ b/bun.lock @@ -31,7 +31,7 @@ }, "adapters/docusaurus-theme-search-algolia": { "name": "@docsearch/docusaurus-adapter", - "version": "5.0.0-beta.1", + "version": "5.0.0-beta.2", "dependencies": { "@algolia/autocomplete-core": "1.19.2", "@docsearch/core": "workspace:*", @@ -115,7 +115,7 @@ }, "packages/docsearch-core": { "name": "@docsearch/core", - "version": "5.0.0-beta.1", + "version": "5.0.0-beta.2", "devDependencies": { "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "16.2.0", @@ -137,7 +137,7 @@ }, "packages/docsearch-css": { "name": "@docsearch/css", - "version": "5.0.0-beta.1", + "version": "5.0.0-beta.2", "devDependencies": { "browserslist": "4.28.2", "lightningcss": "1.32.0", @@ -146,7 +146,7 @@ }, "packages/docsearch-js": { "name": "@docsearch/js", - "version": "5.0.0-beta.1", + "version": "5.0.0-beta.2", "dependencies": { "@docsearch/core": "4.6.0", "@docsearch/react": "4.6.0", @@ -160,7 +160,7 @@ }, "packages/docsearch-modal": { "name": "@docsearch/modal", - "version": "5.0.0-beta.1", + "version": "5.0.0-beta.2", "dependencies": { "@docsearch/core": "4.6.0", "@docsearch/react": "4.6.0", @@ -185,13 +185,13 @@ }, "packages/docsearch-react": { "name": "@docsearch/react", - "version": "5.0.0-beta.1", + "version": "5.0.0-beta.2", "dependencies": { "@ai-sdk/react": "^2.0.30", "@algolia/autocomplete-core": "1.19.2", "@base-ui/react": "^1.5.0", - "@docsearch/core": "5.0.0-beta.1", - "@docsearch/css": "5.0.0-beta.1", + "@docsearch/core": "5.0.0-beta.2", + "@docsearch/css": "5.0.0-beta.2", "ai": "^5.0.30", "algoliasearch": "^5.28.0", "marked": "^16.3.0", @@ -199,7 +199,7 @@ }, "devDependencies": { "@algolia/autocomplete-core": "1.19.2", - "@docsearch/core": "5.0.0-beta.1", + "@docsearch/core": "5.0.0-beta.2", "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "16.2.0", "preact": "11.0.0-beta.0", @@ -222,7 +222,7 @@ }, "packages/docsearch-sidepanel": { "name": "@docsearch/sidepanel", - "version": "5.0.0-beta.1", + "version": "5.0.0-beta.2", "dependencies": { "@docsearch/core": "4.6.0", "@docsearch/css": "4.6.0", @@ -248,7 +248,7 @@ }, "packages/docsearch-sidepanel-js": { "name": "@docsearch/sidepanel-js", - "version": "5.0.0-beta.1", + "version": "5.0.0-beta.2", "dependencies": { "@docsearch/core": "4.6.0", "@docsearch/react": "4.6.0", diff --git a/examples/demo-js/package.json b/examples/demo-js/package.json index 0b622f14..fc753742 100644 --- a/examples/demo-js/package.json +++ b/examples/demo-js/package.json @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "@docsearch/css": "5.0.0-beta.2", - "@docsearch/js": "5.0.0-beta.2", - "@docsearch/sidepanel-js": "5.0.0-beta.2" + "@docsearch/css": "workspace:*", + "@docsearch/js": "workspace:*", + "@docsearch/sidepanel-js": "workspace:*" }, "devDependencies": { "vite": "^6.0.7" diff --git a/examples/demo-js/src/main.ts b/examples/demo-js/src/main.ts index 0d59115a..d2bba01c 100644 --- a/examples/demo-js/src/main.ts +++ b/examples/demo-js/src/main.ts @@ -1,7 +1,4 @@ -import docsearch, { - type DocSearchInstance, - type TemplateHelpers, -} from '@docsearch/js'; +import docsearch, { type DocSearchInstance } from '@docsearch/js'; import sidepanel, { type SidepanelInstance } from '@docsearch/sidepanel-js'; import './app.css'; @@ -91,10 +88,7 @@ docsearchInstance = docsearch({ // eslint-disable-next-line no-console console.log('[demo-js] docsearch onClose()'); }, - resultsFooterComponent: ({ state }, helpers?: TemplateHelpers) => { - const { html } = helpers || {}; - if (!html) return null; - + resultsFooterComponent: ({ state }, { html }) => { return html`
diff --git a/examples/demo-react/src/examples/basic-askai.tsx b/examples/demo-react/src/examples/basic-askai.tsx index 66a4d00f..efb53a2f 100644 --- a/examples/demo-react/src/examples/basic-askai.tsx +++ b/examples/demo-react/src/examples/basic-askai.tsx @@ -23,6 +23,10 @@ const customTools: ToolCalls = { }, }; +const FooterAction = (): JSX.Element => { + return Bonjour; +}; + export default function BasicAskAI({ theme, }: { @@ -50,6 +54,7 @@ export default function BasicAskAI({ translations={{ button: { buttonText: 'Search with Ask AI' } }} theme={theme} resultBadgeKey="type" + footerAction={} /> ); } diff --git a/packages/docsearch-css/src/modal.css b/packages/docsearch-css/src/modal.css index 5d3fea32..3169b63d 100644 --- a/packages/docsearch-css/src/modal.css +++ b/packages/docsearch-css/src/modal.css @@ -1034,7 +1034,6 @@ border-block-start: 1px solid var(--docsearch-subtle-color); border-radius: 0 0 var(--docsearch-modal-radius) var(--docsearch-modal-radius); display: flex; - flex-direction: row-reverse; flex-shrink: 0; height: var(--docsearch-footer-height); justify-content: space-between; @@ -1091,6 +1090,23 @@ text-transform: uppercase; } +.DocSearch-Footer-Actions { + display: flex; + align-items: center; + gap: 1rem; + justify-content: space-between; + width: 100%; + + @media screen and (width > 768px) { + justify-content: unset; + width: auto; + } + + &:not(:has(.DocSearch-Footer-Action)) .DocSearch-Logo { + margin-inline-start: auto; + } +} + /* Hide element accessibly, so that it is still accessible to assistive tech users */ .DocSearch-VisuallyHiddenForAccessibility { diff --git a/packages/docsearch-js/src/__tests__/createDocSearch.test.tsx b/packages/docsearch-js/src/__tests__/createDocSearch.test.tsx new file mode 100644 index 00000000..fb4c221a --- /dev/null +++ b/packages/docsearch-js/src/__tests__/createDocSearch.test.tsx @@ -0,0 +1,103 @@ +import { createElement, type JSX } from 'preact'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { createDocSearch, type DocSearchInstance } from '../createDocSearch'; +import type { FooterActionFn as KeywordFooterActionFn } from '../docsearch'; +import type { FooterActionFn as AskAiFooterActionFn } from '../index'; + +interface TestComponentProps { + footerAction?: JSX.Element | null; +} + +function TestComponent({ footerAction }: TestComponentProps): JSX.Element { + return createElement('div', null, footerAction); +} + +const askAiFooterAction: AskAiFooterActionFn = (_props, { html }) => + html``; + +const keywordFooterAction: KeywordFooterActionFn = (_props, { html }) => + html``; + +describe('createDocSearch', () => { + let instance: DocSearchInstance | undefined; + + afterEach(() => { + instance?.destroy(); + instance = undefined; + }); + + it('adapts footerAction templates for the rendered component', () => { + const container = document.createElement('div'); + const footerAction = (): JSX.Element => + createElement('button', { type: 'button' }, 'Footer action'); + const docsearch = createDocSearch( + TestComponent, + 'test' + ); + + instance = docsearch({ container, footerAction }); + + expect(container.textContent).toContain('Footer action'); + }); + + it('provides the html helper to footerAction templates', () => { + const container = document.createElement('div'); + const docsearch = createDocSearch( + TestComponent, + 'test' + ); + + instance = docsearch({ container, footerAction: askAiFooterAction }); + + expect(container.textContent).toContain('Ask AI footer action'); + }); + + it('accepts footerAction templates from the keyword-only entry point', () => { + const container = document.createElement('div'); + const docsearch = createDocSearch( + TestComponent, + 'test' + ); + + instance = docsearch({ container, footerAction: keywordFooterAction }); + + expect(container.textContent).toContain('Keyword footer action'); + }); + + it('renders string and component footerAction template returns', () => { + const container = document.createElement('div'); + const docsearch = createDocSearch( + TestComponent, + 'test' + ); + + instance = docsearch({ + container, + footerAction: () => () => + createElement('button', { type: 'button' }, 'Component footer action'), + }); + + expect(container.textContent).toContain('Component footer action'); + + instance.destroy(); + instance = docsearch({ + container, + footerAction: () => 'Text footer action', + }); + + expect(container.textContent).toContain('Text footer action'); + }); + + it('supports footerAction templates that return null', () => { + const container = document.createElement('div'); + const docsearch = createDocSearch( + TestComponent, + 'test' + ); + + instance = docsearch({ container, footerAction: () => null }); + + expect(container.textContent).toBe(''); + }); +}); diff --git a/packages/docsearch-js/src/createDocSearch.tsx b/packages/docsearch-js/src/createDocSearch.tsx index ffa5e623..43b7f512 100644 --- a/packages/docsearch-js/src/createDocSearch.tsx +++ b/packages/docsearch-js/src/createDocSearch.tsx @@ -1,4 +1,8 @@ import type { DocSearchRef, InitialAskAiMessage } from '@docsearch/core'; +import type { + ResultsFooterComponentProps, + HitComponentProps, +} from '@docsearch/react'; import htm from 'htm'; import type { ComponentType, JSX, Attributes } from 'preact'; import { @@ -25,10 +29,64 @@ export interface DocSearchCallbacks { interceptAskAiEvent?: (initialMessage: InitialAskAiMessage) => boolean | void; } +export type TemplateHelpers = { html: typeof html }; + +// Defines the public facing interface for each "template" function +type TemplateFnReturnType = JSX.Element | string | (() => JSX.Element) | null; + +export type HitComponentFn = ( + props: HitComponentProps, + helpers: TemplateHelpers +) => TemplateFnReturnType; + +export type ResultsFooterComponentFn = ( + props: ResultsFooterComponentProps, + helpers: TemplateHelpers +) => TemplateFnReturnType; + +export type FooterActionFn = ( + props: never, + helpers: TemplateHelpers +) => TemplateFnReturnType; + export type DocSearchProps = DocSearchCallbacks & - Omit & { + Omit< + TProps, + | 'onSidepanelClose' + | 'onSidepanelOpen' + | 'hitComponent' + | 'resultsFooterComponent' + | 'footerAction' + > & { container: HTMLElement | string; environment?: typeof window; + /** + * Custom component to render an individual hit. Supports template patterns: + * + * - HTML strings with html helper: (props, { html }) => html`
...
` + * - JSX templates: (props) =>
...
+ * - Function-based templates: (props) => string | JSX.Element | Function. + */ + hitComponent?: HitComponentFn; + /** + * Custom component rendered at the bottom of the results panel. Supports + * template patterns: + * + * - HTML strings with html helper: (props, { html }) => html`
...
` + * - JSX templates: (props) =>
...
+ * - Function-based templates: (props) => string | JSX.Element | Function. + */ + resultsFooterComponent?: ResultsFooterComponentFn; + /** + * A custom action that can be rendered in the Modal's footer before the + * Algolia logo. The component will be rendered as a child of `
`. Supports template patterns: + * + * - HTML strings with html helper: (props, { html }) => html`
...
` + * - JSX templates: (props) =>
...
+ * - Function-based templates: (props) => string | JSX.Element | Function. + */ + footerAction?: FooterActionFn; }; function getHTMLElement( @@ -51,14 +109,12 @@ const html = htm.bind(createElement) as unknown as ( ...values: unknown[] ) => JSX.Element; -export type TemplateHelpers = Record & { html: typeof html }; - function createTemplateFunction< - P extends Record, - R = JSX.Element | string | (() => JSX.Element), + P = Record, + R = TemplateFnReturnType, >( - original: ((props: P, helpers?: TemplateHelpers) => R) | undefined -): ((props: P) => JSX.Element) | undefined { + original: ((props: P, helpers: TemplateHelpers) => R) | undefined +): ((props: P) => JSX.Element | null) | undefined { if (!original) return undefined; return (props: P) => { @@ -73,15 +129,10 @@ function createTemplateFunction< } interface ComponentProps { - hitComponent?: ( - props: Record, - helpers?: TemplateHelpers - ) => JSX.Element; - resultsFooterComponent?: ( - props: Record, - helpers?: TemplateHelpers - ) => JSX.Element | null; + hitComponent?: HitComponentFn; + resultsFooterComponent?: ResultsFooterComponentFn; transformSearchClient?: (searchClient: unknown) => unknown; + footerAction?: FooterActionFn; } export function createDocSearch( @@ -96,6 +147,7 @@ export function createDocSearch( transformSearchClient, hitComponent, resultsFooterComponent, + footerAction, ...rest } = input; const containerElement = getHTMLElement( @@ -105,11 +157,19 @@ export function createDocSearch( const ref = createRef(); let isReady = false; + const FooterAction = createTemplateFunction(footerAction); + const props: TComponentProps = { ...rest, ref, - hitComponent: createTemplateFunction(hitComponent), - resultsFooterComponent: createTemplateFunction(resultsFooterComponent), + hitComponent: createTemplateFunction(hitComponent), + resultsFooterComponent: + createTemplateFunction( + resultsFooterComponent + ), + footerAction: FooterAction + ? createElement(FooterAction, null) + : undefined, transformSearchClient: (searchClient: unknown): unknown => { if ( typeof searchClient === 'object' && diff --git a/packages/docsearch-js/src/docsearch.ts b/packages/docsearch-js/src/docsearch.ts index 11fa3aaa..2ca97b73 100644 --- a/packages/docsearch-js/src/docsearch.ts +++ b/packages/docsearch-js/src/docsearch.ts @@ -1,7 +1,12 @@ +export type { + HitComponentFn, + ResultsFooterComponentFn, + FooterActionFn, + TemplateHelpers, +} from './createDocSearch'; export { docsearch as default } from './docsearchComponent'; export type { DocSearchCallbacks, DocSearchInstance, DocSearchProps, - TemplateHelpers, } from './docsearchComponent'; diff --git a/packages/docsearch-js/src/docsearchAi.tsx b/packages/docsearch-js/src/docsearchAi.tsx index 3d064eb3..6e78c569 100644 --- a/packages/docsearch-js/src/docsearchAi.tsx +++ b/packages/docsearch-js/src/docsearchAi.tsx @@ -8,11 +8,7 @@ import { type DocSearchProps as CreateDocSearchProps, } from './createDocSearch'; -export type { - DocSearchCallbacks, - DocSearchInstance, - TemplateHelpers, -} from './createDocSearch'; +export type { DocSearchCallbacks, DocSearchInstance } from './createDocSearch'; export type DocSearchAIProps = CreateDocSearchProps; export const docsearchAi: (allProps: DocSearchAIProps) => DocSearchInstance = diff --git a/packages/docsearch-js/src/docsearchComponent.tsx b/packages/docsearch-js/src/docsearchComponent.tsx index e77b252a..c28f65a3 100644 --- a/packages/docsearch-js/src/docsearchComponent.tsx +++ b/packages/docsearch-js/src/docsearchComponent.tsx @@ -7,11 +7,7 @@ import { type DocSearchProps as CreateDocSearchProps, } from './createDocSearch'; -export type { - DocSearchCallbacks, - DocSearchInstance, - TemplateHelpers, -} from './createDocSearch'; +export type { DocSearchCallbacks, DocSearchInstance } from './createDocSearch'; export type DocSearchProps = CreateDocSearchProps; export const docsearch: (allProps: DocSearchProps) => DocSearchInstance = diff --git a/packages/docsearch-js/src/index.ts b/packages/docsearch-js/src/index.ts index cb66f3a1..de8cdb9e 100644 --- a/packages/docsearch-js/src/index.ts +++ b/packages/docsearch-js/src/index.ts @@ -1,7 +1,12 @@ +export type { + HitComponentFn, + ResultsFooterComponentFn, + FooterActionFn, + TemplateHelpers, +} from './createDocSearch'; export { docsearchAi as default } from './docsearchAi'; export type { DocSearchAIProps as DocSearchProps, DocSearchInstance, DocSearchCallbacks, - TemplateHelpers, } from './docsearchAi'; diff --git a/packages/docsearch-react/src/DocSearch.tsx b/packages/docsearch-react/src/DocSearch.tsx index db8bf842..ca90021c 100644 --- a/packages/docsearch-react/src/DocSearch.tsx +++ b/packages/docsearch-react/src/DocSearch.tsx @@ -43,6 +43,15 @@ export interface DocSearchFacet { label?: string; } +export interface HitComponentProps { + hit: InternalDocSearchHit | StoredDocSearchHit; + children: React.ReactNode; +} + +export interface ResultsFooterComponentProps { + state: AutocompleteState; +} + export interface DocSearchProps { /** Algolia application id used by the search client. */ appId: string; @@ -69,38 +78,18 @@ export interface DocSearchProps { maxResultsPerGroup?: number; /** Hook to post-process hits before rendering. */ transformItems?: (items: DocSearchHit[]) => DocSearchHit[]; - /** - * Custom component to render an individual hit. Supports template patterns: - * - * - HTML strings with html helper: (props, { html }) => html`
...
` - * - JSX templates: (props) =>
...
- * - Function-based templates: (props) => string | JSX.Element | Function. - */ - hitComponent?: ( - props: { - hit: InternalDocSearchHit | StoredDocSearchHit; - children: React.ReactNode; - }, - helpers?: { - html: (template: TemplateStringsArray, ...values: any[]) => any; - } - ) => JSX.Element; - /** - * Custom component rendered at the bottom of the results panel. Supports - * template patterns: - * - * - HTML strings with html helper: (props, { html }) => html`
...
` - * - JSX templates: (props) =>
...
- * - Function-based templates: (props) => string | JSX.Element | Function. - */ + /** Custom component to render an individual hit. */ + hitComponent?: (props: HitComponentProps) => JSX.Element; + /** Custom component rendered at the bottom of the results panel. */ resultsFooterComponent?: ( - props: { - state: AutocompleteState; - }, - helpers?: { - html: (template: TemplateStringsArray, ...values: any[]) => any; - } + props: ResultsFooterComponentProps ) => JSX.Element | null; + /** + * A custom action that can be rendered in the Modal's footer before the + * Algolia logo. The component will be rendered as a child of `
`. + */ + footerAction?: React.ReactNode; /** Hook to wrap or modify the algolia search client. */ transformSearchClient?: ( searchClient: DocSearchTransformClient diff --git a/packages/docsearch-react/src/DocSearchAskAiModal.tsx b/packages/docsearch-react/src/DocSearchAskAiModal.tsx index 999d0754..e2be8995 100644 --- a/packages/docsearch-react/src/DocSearchAskAiModal.tsx +++ b/packages/docsearch-react/src/DocSearchAskAiModal.tsx @@ -102,6 +102,7 @@ export function DocSearchAskAiModal({ indices, facets, isHybridModeSupported = false, + footerAction, ...props }: DocSearchAskAiModalProps): JSX.Element { const { @@ -645,6 +646,7 @@ export function DocSearchAskAiModal({