From 884864b25d6b39b3d34d907c8847f724e8d2921b Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Fri, 31 Oct 2025 11:16:31 +0100 Subject: [PATCH 1/5] feat(autocomplete): support header template for autocomplete index --- .../autocomplete/AutocompleteIndex.tsx | 18 ++++++++++++- .../src/widgets/autocomplete/autocomplete.tsx | 27 ++++++++++++++++--- .../src/widgets/Autocomplete.tsx | 20 +++++++------- 3 files changed, 51 insertions(+), 14 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/autocomplete/AutocompleteIndex.tsx b/packages/instantsearch-ui-components/src/components/autocomplete/AutocompleteIndex.tsx index f98795fbea..ea06c91d3e 100644 --- a/packages/instantsearch-ui-components/src/components/autocomplete/AutocompleteIndex.tsx +++ b/packages/instantsearch-ui-components/src/components/autocomplete/AutocompleteIndex.tsx @@ -8,6 +8,7 @@ export type AutocompleteIndexProps< T = { objectID: string; __indexName: string } & Record > = { items: T[]; + HeaderComponent?: (props: { items: T[] }) => JSX.Element; ItemComponent: (props: { item: T; onSelect: () => void }) => JSX.Element; getItemProps: ( item: T, @@ -25,6 +26,10 @@ export type AutocompleteIndexClassNames = { * Class names to apply to the list element */ list: string | string[]; + /** + * Class names to apply to the header element + */ + header: string | string[]; /** * Class names to apply to each item element */ @@ -33,10 +38,21 @@ export type AutocompleteIndexClassNames = { export function createAutocompleteIndexComponent({ createElement }: Renderer) { return function AutocompleteIndex(userProps: AutocompleteIndexProps) { - const { items, ItemComponent, getItemProps, classNames = {} } = userProps; + const { + items, + HeaderComponent, + ItemComponent, + getItemProps, + classNames = {}, + } = userProps; return (
+ {HeaderComponent && ( +
+ +
+ )}
    {items.map((item, index) => { const { className, onSelect, ...itemProps } = getItemProps( diff --git a/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx b/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx index b0e0e60017..5d1da304d2 100644 --- a/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx +++ b/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx @@ -200,6 +200,22 @@ function AutocompleteWrapper({ templates: indicesConfig[i].templates, }); } + const headerComponent = indicesConfig[i].templates?.header + ? ({ + items, + }: Parameters< + NonNullable + >[0]) => { + return ( + + ); + } + : undefined; const itemComponent = ({ item, onSelect, @@ -217,6 +233,7 @@ function AutocompleteWrapper({ return ( ({ ...item, __indexName: indexId }))} getItemProps={getItemProps} @@ -237,6 +254,10 @@ export type AutocompleteTemplates = Partial< type IndexConfig = AutocompleteIndexConfig & { templates?: Partial<{ + /** + * Template to use for the header, before the list of items. + */ + header: Template<{ items: TItem[] }>; /** * Template to use for each result. This template will receive an object containing a single record. */ @@ -312,9 +333,9 @@ export function EXPERIMENTAL_autocomplete( indicesConfig.unshift({ indexName: showSuggestions.indexName, templates: { - // Temporarily force casting until the coming refactoring - item: (showSuggestions.templates?.item || - AutocompleteSuggestion) as unknown as Template<{ item: TItem }>, + // @ts-expect-error + item: AutocompleteSuggestion, + ...showSuggestions.templates, }, cssClasses: { root: cx(suggestionsSuit(), showSuggestions.cssClasses?.root), diff --git a/packages/react-instantsearch/src/widgets/Autocomplete.tsx b/packages/react-instantsearch/src/widgets/Autocomplete.tsx index 097dc97ecd..b0fc21e752 100644 --- a/packages/react-instantsearch/src/widgets/Autocomplete.tsx +++ b/packages/react-instantsearch/src/widgets/Autocomplete.tsx @@ -24,8 +24,9 @@ import type { AutocompleteIndexConfig, Pragma, AutocompleteClassNames, + AutocompleteIndexProps, } from 'instantsearch-ui-components'; -import type { BaseHit, Hit } from 'instantsearch.js'; +import type { BaseHit } from 'instantsearch.js'; import type { ComponentProps } from 'react'; const Autocomplete = createAutocompleteComponent({ @@ -56,13 +57,9 @@ const usePropGetters = createAutocompletePropGetters({ useState, }); -type ItemComponentProps = React.ComponentType<{ - item: Hit; - onSelect: () => void; -}>; - type IndexConfig = AutocompleteIndexConfig & { - itemComponent: ItemComponentProps; + headerComponent?: AutocompleteIndexProps['HeaderComponent']; + itemComponent: AutocompleteIndexProps['ItemComponent']; classNames?: Partial; }; @@ -71,7 +68,7 @@ export type AutocompleteProps = ComponentProps<'div'> & { showSuggestions?: Partial< Pick< IndexConfig<{ query: string }>, - 'indexName' | 'itemComponent' | 'classNames' + 'indexName' | 'headerComponent' | 'itemComponent' | 'classNames' > >; classNames?: Partial; @@ -98,9 +95,10 @@ export function EXPERIMENTAL_Autocomplete({ if (showSuggestions?.indexName) { indicesConfig.unshift({ indexName: showSuggestions.indexName, - // Temporarily force casting until the coming refactoring + headerComponent: + showSuggestions.headerComponent as unknown as AutocompleteIndexProps['HeaderComponent'], itemComponent: (showSuggestions.itemComponent || - AutocompleteSuggestion) as unknown as ItemComponentProps, + AutocompleteSuggestion) as unknown as AutocompleteIndexProps['ItemComponent'], classNames: { root: cx( 'ais-AutocompleteSuggestions', @@ -162,6 +160,8 @@ function InnerAutocomplete({ ({ ...item, From dce153df33743c06a95e7c876854f6fa8ae89434 Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:19:33 +0100 Subject: [PATCH 2/5] add test --- .../src/widgets/autocomplete/autocomplete.tsx | 14 +- .../src/widgets/Autocomplete.tsx | 4 + tests/common/widgets/autocomplete/index.ts | 2 + .../common/widgets/autocomplete/templates.tsx | 123 ++++++++++++++++++ 4 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 tests/common/widgets/autocomplete/templates.tsx diff --git a/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx b/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx index 5d1da304d2..52dcfed5e5 100644 --- a/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx +++ b/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx @@ -329,7 +329,6 @@ export function EXPERIMENTAL_autocomplete( const indicesConfig = [...indices]; if (showSuggestions?.indexName) { - const suggestionsSuit = component('AutocompleteSuggestions'); indicesConfig.unshift({ indexName: showSuggestions.indexName, templates: { @@ -338,13 +337,20 @@ export function EXPERIMENTAL_autocomplete( ...showSuggestions.templates, }, cssClasses: { - root: cx(suggestionsSuit(), showSuggestions.cssClasses?.root), + root: cx( + 'ais-AutocompleteSuggestions', + showSuggestions.cssClasses?.root + ), list: cx( - suggestionsSuit({ descendantName: 'list' }), + 'ais-AutocompleteSuggestionsList', showSuggestions.cssClasses?.list ), + header: cx( + 'ais-AutocompleteSuggestionsHeader', + showSuggestions.cssClasses?.header + ), item: cx( - suggestionsSuit({ descendantName: 'item' }), + 'ais-AutocompleteSuggestionsItem', showSuggestions.cssClasses?.item ), }, diff --git a/packages/react-instantsearch/src/widgets/Autocomplete.tsx b/packages/react-instantsearch/src/widgets/Autocomplete.tsx index b0fc21e752..547fa1d4ba 100644 --- a/packages/react-instantsearch/src/widgets/Autocomplete.tsx +++ b/packages/react-instantsearch/src/widgets/Autocomplete.tsx @@ -108,6 +108,10 @@ export function EXPERIMENTAL_Autocomplete({ 'ais-AutocompleteSuggestionsList', showSuggestions?.classNames?.list ), + header: cx( + 'ais-AutocompleteSuggestionsHeader', + showSuggestions?.classNames?.header + ), item: cx( 'ais-AutocompleteSuggestionsItem', showSuggestions?.classNames?.item diff --git a/tests/common/widgets/autocomplete/index.ts b/tests/common/widgets/autocomplete/index.ts index 331c97c887..4758c449ce 100644 --- a/tests/common/widgets/autocomplete/index.ts +++ b/tests/common/widgets/autocomplete/index.ts @@ -1,6 +1,7 @@ import { fakeAct, skippableDescribe } from '../../common'; import { createOptionsTests } from './options'; +import { createTemplatesTests } from './templates'; import type { TestOptions, TestSetup } from '../../common'; import type { AutocompleteConnectorParams } from 'instantsearch.js/es/connectors/autocomplete/connectAutocomplete'; @@ -38,6 +39,7 @@ export function createAutocompleteWidgetTests( skippableDescribe('Autocomplete widget common tests', skippedTests, () => { createOptionsTests(setup, { act, skippedTests, flavor }); + createTemplatesTests(setup, { act, skippedTests, flavor }); }); } createAutocompleteWidgetTests.flavored = true; diff --git a/tests/common/widgets/autocomplete/templates.tsx b/tests/common/widgets/autocomplete/templates.tsx new file mode 100644 index 0000000000..0c555ad0d7 --- /dev/null +++ b/tests/common/widgets/autocomplete/templates.tsx @@ -0,0 +1,123 @@ +import { + createMultiSearchResponse, + createSearchClient, + createSingleSearchResponse, +} from '@instantsearch/mocks'; +import { wait } from '@instantsearch/testutils'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import type { AutocompleteWidgetSetup } from '.'; +import type { TestOptions } from '../../common'; + +export function createTemplatesTests( + setup: AutocompleteWidgetSetup, + { act }: Required +) { + describe('templates', () => { + test('renders indices headers', async () => { + const searchClient = createMockedSearchClient( + createMultiSearchResponse( + createSingleSearchResponse({ + index: 'indexName2', + hits: [ + { objectID: '1', query: 'hello' }, + { objectID: '2', query: 'world' }, + ], + }), + // @ts-expect-error - ignore second response type + createSingleSearchResponse({ + index: 'indexName', + hits: [ + { objectID: '1', name: 'Item 1' }, + { objectID: '2', name: 'Item 2' }, + { objectID: '3', name: 'Item 3' }, + ], + }) + ) + ); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + indices: [ + { + indexName: 'indexName', + templates: { + item: (props) => props.item.name, + header: (props) => + `${props.items.length} results`, + }, + cssClasses: { header: 'HEADER' }, + }, + ], + showSuggestions: { + indexName: 'indexName2', + templates: { + header: (props) => `${props.items.length} results`, + }, + cssClasses: { header: 'HEADER' }, + }, + }, + react: { + indices: [ + { + indexName: 'indexName', + itemComponent: (props) => props.item.name, + headerComponent: (props) => ( + {props.items.length} results + ), + classNames: { header: 'HEADER' }, + }, + ], + showSuggestions: { + indexName: 'indexName2', + headerComponent: (props) => ( + {props.items.length} results + ), + classNames: { header: 'HEADER' }, + }, + }, + vue: {}, + }, + }); + + await act(async () => { + await wait(0); + // JS currently doesn't refine on focus + const input = document.querySelector('.ais-SearchBox-input')!; + userEvent.click(input); + userEvent.type(input, 'a'); + userEvent.clear(input); + + await wait(0); + }); + + const headers = [ + ...document.querySelectorAll('.ais-AutocompleteIndexHeader'), + ]; + expect(headers).toHaveLength(2); + expect(headers.map((header) => header.className)).toEqual([ + 'ais-AutocompleteIndexHeader ais-AutocompleteSuggestionsHeader HEADER', + 'ais-AutocompleteIndexHeader HEADER', + ]); + expect(headers.map((header) => header.textContent)).toEqual([ + '2 results', + '3 results', + ]); + }); + }); +} + +function createMockedSearchClient( + response: ReturnType +) { + return createSearchClient({ + // @ts-expect-error - doesn't properly handle multi index, expects all responses to be of the same type + search: jest.fn(() => Promise.resolve(response)), + }); +} From 28991c457005fc0b0f56a8b65a49a344f087d31c Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:25:03 +0100 Subject: [PATCH 3/5] add styles --- bundlesize.config.json | 2 +- .../src/components/autocomplete.scss | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/bundlesize.config.json b/bundlesize.config.json index c75e673797..d62fadf075 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -10,7 +10,7 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.production.min.js", - "maxSize": "87.75 kB" + "maxSize": "88 kB" }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", diff --git a/packages/instantsearch.css/src/components/autocomplete.scss b/packages/instantsearch.css/src/components/autocomplete.scss index 689a35498f..b5a7dfd195 100644 --- a/packages/instantsearch.css/src/components/autocomplete.scss +++ b/packages/instantsearch.css/src/components/autocomplete.scss @@ -303,6 +303,47 @@ } } +// Header +.ais-AutocompleteIndexHeader { + margin: var(--ais-autocomplete-spacing-half) 0.5em var(--ais-autocomplete-spacing-half) 0; + padding: 0; + position: relative; + + + &:empty { + display: none; + } +} + +.ais-AutocompleteIndexHeaderTitle { + background: rgba( + var(--ais-autocomplete-background-color-rgb), + var(--ais-autocomplete-background-color-alpha) + ); + color: rgba(var(--ais-autocomplete-primary-color-rgb), 1); + display: inline-block; + font-size: 0.8em; + font-weight: var(--ais-autocomplete-font-weight-semibold); + margin: 0; + padding: 0 var(--ais-autocomplete-spacing-half) 0 0; + position: relative; + z-index: var(--ais-autocomplete-base-z-index); +} + +.ais-AutocompleteIndexHeaderLine { + border-bottom: solid 1px rgba(var(--ais-autocomplete-primary-color-rgb), 1); + display: block; + height: 2px; + left: 0; + margin: 0; + opacity: 0.3; + padding: 0; + position: absolute; + right: 0; + top: var(--ais-autocomplete-spacing-half); + z-index: calc(var(--ais-autocomplete-base-z-index) - 1); +} + // Items .ais-AutocompleteIndexItem { align-items: center; From 7848b57701b2d2ad2d65cc369e68057947282a5f Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:29:13 +0100 Subject: [PATCH 4/5] fix test --- tests/common/widgets/autocomplete/templates.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/common/widgets/autocomplete/templates.tsx b/tests/common/widgets/autocomplete/templates.tsx index 0c555ad0d7..4fe78bceeb 100644 --- a/tests/common/widgets/autocomplete/templates.tsx +++ b/tests/common/widgets/autocomplete/templates.tsx @@ -4,6 +4,7 @@ import { createSingleSearchResponse, } from '@instantsearch/mocks'; import { wait } from '@instantsearch/testutils'; +import { screen } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -12,7 +13,7 @@ import type { TestOptions } from '../../common'; export function createTemplatesTests( setup: AutocompleteWidgetSetup, - { act }: Required + { act, flavor }: Required ) { describe('templates', () => { test('renders indices headers', async () => { @@ -88,8 +89,14 @@ export function createTemplatesTests( await act(async () => { await wait(0); + // JS currently doesn't refine on focus - const input = document.querySelector('.ais-SearchBox-input')!; + const input = + flavor === 'javascript' + ? document.querySelector('.ais-SearchBox-input')! + : screen.getByRole('combobox', { + name: /submit/i, + }); userEvent.click(input); userEvent.type(input, 'a'); userEvent.clear(input); From 399fee0dfeaea49227463633c3d5c09d11e766b2 Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:38:57 +0100 Subject: [PATCH 5/5] bump bundlesize again --- bundlesize.config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bundlesize.config.json b/bundlesize.config.json index d62fadf075..42b3d37409 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -46,7 +46,7 @@ }, { "path": "./packages/instantsearch.css/themes/algolia-min.css", - "maxSize": "8 kB" + "maxSize": "8.25 kB" }, { "path": "./packages/instantsearch.css/themes/reset.css", @@ -62,7 +62,7 @@ }, { "path": "./packages/instantsearch.css/themes/satellite-min.css", - "maxSize": "9 kB" + "maxSize": "9.25 kB" }, { "path": "./packages/instantsearch.css/components/chat.css",