From 98381bb8d15aec42446259bae5d29f4f8b87d243 Mon Sep 17 00:00:00 2001 From: Sarah Dayan <5370675+sarahdayan@users.noreply.github.com> Date: Sun, 22 Feb 2026 08:33:41 +0100 Subject: [PATCH 1/3] feat: add `ais.infiniteHits` widget support Co-Authored-By: Claude Opus 4.6 --- packages/runtime/src/experiences/types.ts | 3 + packages/runtime/src/experiences/widget.tsx | 10 ++ packages/toolbar/__tests__/ai-tools.test.ts | 185 ++++++++++++++++++++ packages/toolbar/__tests__/toolbar.test.ts | 3 +- packages/toolbar/src/widget-types.tsx | 60 ++++++- 5 files changed, 259 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/experiences/types.ts b/packages/runtime/src/experiences/types.ts index 44adbca..4ac221c 100644 --- a/packages/runtime/src/experiences/types.ts +++ b/packages/runtime/src/experiences/types.ts @@ -6,6 +6,7 @@ import type { import type { ChatWidget } from 'instantsearch.js/es/widgets/chat/chat'; import type { ConfigureWidget } from 'instantsearch.js/es/widgets/configure/configure'; import type { HitsWidget } from 'instantsearch.js/es/widgets/hits/hits'; +import type { InfiniteHitsWidget } from 'instantsearch.js/es/widgets/infinite-hits/infinite-hits'; import type { SearchBoxWidget } from 'instantsearch.js/es/widgets/search-box/search-box'; export type Environment = 'prod' | 'beta'; @@ -57,12 +58,14 @@ export type ExperienceWidget = Widget & { 'ais.configure': SupportedWidget[0]>; 'ais.autocomplete': SupportedWidget; 'ais.hits': SupportedWidget[0]>; + 'ais.infiniteHits': SupportedWidget[0]>; 'ais.searchBox': SupportedWidget[0]>; } & Record< | 'ais.chat' | 'ais.configure' | 'ais.autocomplete' | 'ais.hits' + | 'ais.infiniteHits' | 'ais.searchBox' | (string & {}), SupportedWidget diff --git a/packages/runtime/src/experiences/widget.tsx b/packages/runtime/src/experiences/widget.tsx index 73e564e..ba0f858 100644 --- a/packages/runtime/src/experiences/widget.tsx +++ b/packages/runtime/src/experiences/widget.tsx @@ -6,6 +6,7 @@ import { getExperience } from './get-experience'; import chat from 'instantsearch.js/es/widgets/chat/chat'; import configure from 'instantsearch.js/es/widgets/configure/configure'; import hits from 'instantsearch.js/es/widgets/hits/hits'; +import infiniteHits from 'instantsearch.js/es/widgets/infinite-hits/infinite-hits'; import { EXPERIMENTAL_autocomplete } from 'instantsearch.js/es/widgets/autocomplete/autocomplete'; import searchBox from 'instantsearch.js/es/widgets/search-box/search-box'; @@ -149,6 +150,15 @@ export default (function experience(widgetParams: ExperienceWidgetParams) { return parameters; }, }, + // TODO: Add support for `templates` (item, empty, showMoreText) + // TODO: Add support for `transformItems` (bucket 3 function) + // TODO: Add support for `cache` (bucket 3 function) + 'ais.infiniteHits': { + widget: infiniteHits, + async transformParams(parameters) { + return parameters; + }, + }, // TODO: Add support for `templates` (submit, reset, loadingIndicator) // TODO: Add support for `queryHook` (bucket 3 — function) 'ais.searchBox': { diff --git a/packages/toolbar/__tests__/ai-tools.test.ts b/packages/toolbar/__tests__/ai-tools.test.ts index 56e4ce1..b88def9 100644 --- a/packages/toolbar/__tests__/ai-tools.test.ts +++ b/packages/toolbar/__tests__/ai-tools.test.ts @@ -41,6 +41,8 @@ describe('describeWidgetTypes', () => { expect(result).toContain('Configure'); expect(result).toContain('ais.hits'); expect(result).toContain('Hits'); + expect(result).toContain('ais.infiniteHits'); + expect(result).toContain('Infinite Hits'); expect(result).toContain('ais.searchBox'); expect(result).toContain('Search Box'); }); @@ -640,6 +642,130 @@ describe('getTools', () => { }); it('adds ais.index widget at top level', async () => { + const experience: ExperienceApiResponse = { + blocks: [], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.add_widget.execute!( + { type: 'ais.index', parameters: { indexName: 'products' } }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(callbacks.onAddBlock).toHaveBeenCalledWith('ais.index', undefined); + expect(result).toMatchObject({ + success: true, + type: 'ais.index', + }); + }); + + it('skips container and placement inside parameters to avoid duplication', async () => { + const experience: ExperienceApiResponse = { + blocks: [], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + await tools.add_widget.execute!( + { + type: 'ais.autocomplete', + container: '#search', + placement: 'before', + parameters: { + container: '#other', + placement: 'after', + showRecent: true, + }, + }, + { toolCallId: 'tc1', messages: [] } + ); + + // Top-level container and placement should be used + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'container', + '#search' + ); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'placement', + 'before' + ); + // container and placement inside parameters should be skipped + expect(callbacks.onParameterChange).not.toHaveBeenCalledWith( + [0], + 'container', + '#other' + ); + expect(callbacks.onParameterChange).not.toHaveBeenCalledWith( + [0], + 'placement', + 'after' + ); + // Other params inside parameters should still be applied + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'showRecent', + true + ); + }); + + it('adds an infinite hits widget with default parameters', async () => { + const experience: ExperienceApiResponse = { + blocks: [], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.add_widget.execute!( + { type: 'ais.infiniteHits', container: '#hits' }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(callbacks.onAddBlock).toHaveBeenCalledWith( + 'ais.infiniteHits', + undefined + ); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'container', + '#hits' + ); + expect(result).toMatchObject({ + success: true, + type: 'ais.infiniteHits', + }); + }); + + it('adds an infinite hits widget with showPrevious enabled', async () => { + const experience: ExperienceApiResponse = { + blocks: [], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + await tools.add_widget.execute!( + { + type: 'ais.infiniteHits', + container: '#hits', + parameters: { showPrevious: true }, + }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'showPrevious', + true + ); + }); + + it('computes the correct index for non-empty experiences', async () => { const experience: ExperienceApiResponse = { blocks: [ { @@ -1176,6 +1302,65 @@ describe('getTools', () => { ); }); + it('edits infinite hits escapeHTML parameter', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.infiniteHits', + parameters: { container: '#hits', escapeHTML: true }, + }, + ], + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.edit_widget.execute!( + { index: 0, parameters: { escapeHTML: false } }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: true, + applied: ['escapeHTML'], + }); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + 0, + 'escapeHTML', + false + ); + }); + + it('edits infinite hits cssClasses parameter', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.infiniteHits', + parameters: { container: '#hits' }, + }, + ], + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.edit_widget.execute!( + { + index: 0, + parameters: { cssClasses: { root: 'my-root', item: 'my-item' } }, + }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: true, + applied: expect.arrayContaining(['cssClasses']), + }); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + 0, + 'cssClasses', + { root: 'my-root', item: 'my-item' } + ); + }); + it('returns empty applied when all parameters are rejected', async () => { const experience: ExperienceApiResponse = { blocks: [ diff --git a/packages/toolbar/__tests__/toolbar.test.ts b/packages/toolbar/__tests__/toolbar.test.ts index 357a73f..c63b349 100644 --- a/packages/toolbar/__tests__/toolbar.test.ts +++ b/packages/toolbar/__tests__/toolbar.test.ts @@ -168,6 +168,7 @@ describe('toolbar', () => { expect(popoverText).toContain('Search Box'); expect(popoverText).toContain('Configure'); expect(popoverText).toContain('Hits'); + expect(popoverText).toContain('Infinite Hits'); expect(popoverText).toContain('Coming Soon'); }); @@ -311,7 +312,7 @@ describe('toolbar', () => { const host = await openToolbar(); const trigger = - host.shadowRoot?.querySelector('[aria-expanded]')!; + host.shadowRoot!.querySelector('[aria-expanded]')!; // Expand trigger.click(); diff --git a/packages/toolbar/src/widget-types.tsx b/packages/toolbar/src/widget-types.tsx index 050afb6..00657c5 100644 --- a/packages/toolbar/src/widget-types.tsx +++ b/packages/toolbar/src/widget-types.tsx @@ -526,10 +526,68 @@ export const WIDGET_TYPES: Record = { }, 'ais.infiniteHits': { label: 'Infinite Hits', - enabled: false, + description: + 'Displays search results with a "Show more" button to load additional pages incrementally.', + enabled: true, icon: ARROW_DOWN_ICON, defaultParameters: { container: '', + escapeHTML: true, + showPrevious: false, + cssClasses: false, + }, + fieldOrder: [ + 'container', + 'placement', + 'escapeHTML', + 'showPrevious', + 'cssClasses', + ], + fieldOverrides: { + escapeHTML: { type: 'switch', label: 'Escape HTML' }, + showPrevious: { type: 'switch', label: 'Show previous' }, + cssClasses: { + type: 'object', + label: 'CSS classes', + defaultValue: { + root: '', + emptyRoot: '', + list: '', + item: '', + loadPrevious: '', + disabledLoadPrevious: '', + loadMore: '', + disabledLoadMore: '', + bannerRoot: '', + bannerImage: '', + bannerLink: '', + }, + fields: [ + { key: 'root', label: 'Root' }, + { key: 'emptyRoot', label: 'Empty Root' }, + { key: 'list', label: 'List' }, + { key: 'item', label: 'Item' }, + { key: 'loadPrevious', label: 'Load Previous' }, + { key: 'disabledLoadPrevious', label: 'Disabled Load Previous' }, + { key: 'loadMore', label: 'Load More' }, + { key: 'disabledLoadMore', label: 'Disabled Load More' }, + { key: 'bannerRoot', label: 'Banner Root' }, + { key: 'bannerImage', label: 'Banner Image' }, + { key: 'bannerLink', label: 'Banner Link' }, + ], + }, + }, + paramLabels: { + container: 'Container', + }, + paramDescriptions: { + container: + 'CSS selector for the DOM element to render into (e.g. "#infinite-hits").', + escapeHTML: + 'When enabled, escapes HTML entities in hit string values for safety.', + showPrevious: + 'When enabled, shows a button to load previous results above the list.', + cssClasses: 'Custom CSS classes to apply to the widget elements.', }, }, 'ais.sortBy': { From 90f6da319da9f2feedc04f77a9a91c9c28fb0376 Mon Sep 17 00:00:00 2001 From: Sarah Dayan <5370675+sarahdayan@users.noreply.github.com> Date: Sun, 22 Feb 2026 10:56:01 +0100 Subject: [PATCH 2/3] fix(toolbar): apply field type conventions to infiniteHits widget - Change cssClasses from false to undefined with disabledValue: undefined - Add field behavior tests (infinite-hits.test.tsx) Co-Authored-By: Claude Opus 4.6 --- .../toolbar/__tests__/infinite-hits.test.tsx | 88 +++++++++++++++++++ packages/toolbar/src/widget-types.tsx | 3 +- 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 packages/toolbar/__tests__/infinite-hits.test.tsx diff --git a/packages/toolbar/__tests__/infinite-hits.test.tsx b/packages/toolbar/__tests__/infinite-hits.test.tsx new file mode 100644 index 0000000..e017a4b --- /dev/null +++ b/packages/toolbar/__tests__/infinite-hits.test.tsx @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + fireInput, + getInput, + getSwitch, + renderEditor, +} from './widget-test-utils'; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +function render(params: Record = {}) { + return renderEditor(params, 'ais.infiniteHits'); +} + +describe('ais.infiniteHits field behavior', () => { + describe('switch fields (escapeHTML, showPrevious)', () => { + it('sends false when escapeHTML is toggled off', () => { + const { onParameterChange, container } = render({ + escapeHTML: true, + }); + const toggle = getSwitch(container, 'Escape HTML'); + + toggle.click(); + + expect(onParameterChange).toHaveBeenCalledWith('escapeHTML', false); + }); + + it('sends true when showPrevious is toggled on', () => { + const { onParameterChange, container } = render({ + showPrevious: false, + }); + const toggle = getSwitch(container, 'Show previous'); + + toggle.click(); + + expect(onParameterChange).toHaveBeenCalledWith('showPrevious', true); + }); + }); + + describe('object field with disabledValue undefined (cssClasses)', () => { + it('renders the toggle even when cssClasses is absent from parameters', () => { + const { container } = render(); + const toggle = getSwitch(container, 'CSS classes'); + + expect(toggle).not.toBeNull(); + }); + + it('sends the default object when toggled on', () => { + const { onParameterChange, container } = render(); + const toggle = getSwitch(container, 'CSS classes'); + + toggle.click(); + + expect(onParameterChange).toHaveBeenCalledWith( + 'cssClasses', + expect.objectContaining({ root: '', list: '', item: '' }) + ); + }); + + it('sends undefined (not false) when toggled off', () => { + const { onParameterChange, container } = render({ + cssClasses: { root: 'my-root' }, + }); + const toggle = getSwitch(container, 'CSS classes'); + + toggle.click(); + + expect(onParameterChange).toHaveBeenCalledWith('cssClasses', undefined); + }); + + it('sends the updated object when a sub-field changes', () => { + const { onParameterChange, container } = render({ + cssClasses: { root: '', list: '' }, + }); + const input = getInput(container, 'Root'); + + fireInput(input, 'my-root'); + + expect(onParameterChange).toHaveBeenCalledWith( + 'cssClasses', + expect.objectContaining({ root: 'my-root' }) + ); + }); + }); +}); diff --git a/packages/toolbar/src/widget-types.tsx b/packages/toolbar/src/widget-types.tsx index 00657c5..4b90567 100644 --- a/packages/toolbar/src/widget-types.tsx +++ b/packages/toolbar/src/widget-types.tsx @@ -534,7 +534,7 @@ export const WIDGET_TYPES: Record = { container: '', escapeHTML: true, showPrevious: false, - cssClasses: false, + cssClasses: undefined, }, fieldOrder: [ 'container', @@ -549,6 +549,7 @@ export const WIDGET_TYPES: Record = { cssClasses: { type: 'object', label: 'CSS classes', + disabledValue: undefined, defaultValue: { root: '', emptyRoot: '', From b825195bbb29d6283a643167c7191d37240f50e6 Mon Sep 17 00:00:00 2001 From: Sarah Dayan <5370675+sarahdayan@users.noreply.github.com> Date: Sun, 22 Feb 2026 11:15:37 +0100 Subject: [PATCH 3/3] fix(toolbar): normalize multi-word labels to sentence case Co-Authored-By: Claude Opus 4.6 --- packages/toolbar/__tests__/ai-tools.test.ts | 10 ++++++---- packages/toolbar/src/widget-types.tsx | 16 ++++++++-------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/toolbar/__tests__/ai-tools.test.ts b/packages/toolbar/__tests__/ai-tools.test.ts index b88def9..e071a44 100644 --- a/packages/toolbar/__tests__/ai-tools.test.ts +++ b/packages/toolbar/__tests__/ai-tools.test.ts @@ -1310,12 +1310,13 @@ describe('getTools', () => { parameters: { container: '#hits', escapeHTML: true }, }, ], + indexName: '', }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); const result = await tools.edit_widget.execute!( - { index: 0, parameters: { escapeHTML: false } }, + { path: '0', parameters: { escapeHTML: false } }, { toolCallId: 'tc1', messages: [] } ); @@ -1324,7 +1325,7 @@ describe('getTools', () => { applied: ['escapeHTML'], }); expect(callbacks.onParameterChange).toHaveBeenCalledWith( - 0, + [0], 'escapeHTML', false ); @@ -1338,13 +1339,14 @@ describe('getTools', () => { parameters: { container: '#hits' }, }, ], + indexName: '', }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); const result = await tools.edit_widget.execute!( { - index: 0, + path: '0', parameters: { cssClasses: { root: 'my-root', item: 'my-item' } }, }, { toolCallId: 'tc1', messages: [] } @@ -1355,7 +1357,7 @@ describe('getTools', () => { applied: expect.arrayContaining(['cssClasses']), }); expect(callbacks.onParameterChange).toHaveBeenCalledWith( - 0, + [0], 'cssClasses', { root: 'my-root', item: 'my-item' } ); diff --git a/packages/toolbar/src/widget-types.tsx b/packages/toolbar/src/widget-types.tsx index 4b90567..2ce7eec 100644 --- a/packages/toolbar/src/widget-types.tsx +++ b/packages/toolbar/src/widget-types.tsx @@ -565,16 +565,16 @@ export const WIDGET_TYPES: Record = { }, fields: [ { key: 'root', label: 'Root' }, - { key: 'emptyRoot', label: 'Empty Root' }, + { key: 'emptyRoot', label: 'Empty root' }, { key: 'list', label: 'List' }, { key: 'item', label: 'Item' }, - { key: 'loadPrevious', label: 'Load Previous' }, - { key: 'disabledLoadPrevious', label: 'Disabled Load Previous' }, - { key: 'loadMore', label: 'Load More' }, - { key: 'disabledLoadMore', label: 'Disabled Load More' }, - { key: 'bannerRoot', label: 'Banner Root' }, - { key: 'bannerImage', label: 'Banner Image' }, - { key: 'bannerLink', label: 'Banner Link' }, + { key: 'loadPrevious', label: 'Load previous' }, + { key: 'disabledLoadPrevious', label: 'Disabled load previous' }, + { key: 'loadMore', label: 'Load more' }, + { key: 'disabledLoadMore', label: 'Disabled load more' }, + { key: 'bannerRoot', label: 'Banner root' }, + { key: 'bannerImage', label: 'Banner image' }, + { key: 'bannerLink', label: 'Banner link' }, ], }, },