diff --git a/bundlesize.config.json b/bundlesize.config.json index 333f166aa2..7579a0f21f 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -10,11 +10,11 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.production.min.js", - "maxSize": "83 kB" + "maxSize": "83.25 kB" }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", - "maxSize": "180 kB" + "maxSize": "180.25 kB" }, { "path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js", diff --git a/packages/instantsearch-ui-components/src/components/Carousel.tsx b/packages/instantsearch-ui-components/src/components/Carousel.tsx index 5ab986c2c6..452cec3e80 100644 --- a/packages/instantsearch-ui-components/src/components/Carousel.tsx +++ b/packages/instantsearch-ui-components/src/components/Carousel.tsx @@ -1,6 +1,8 @@ /** @jsx createElement */ import { cx } from '../lib'; +import { createDefaultItemComponent } from './recommend-shared'; + import type { ComponentProps, MutableRef, @@ -18,10 +20,12 @@ export type CarouselProps< previousButtonRef: MutableRef; carouselIdRef: MutableRef; items: Array>; - itemComponent: ( + itemComponent?: ( props: RecommendItemComponentProps> & TComponentProps ) => JSX.Element; + previousIconComponent?: () => JSX.Element; + nextIconComponent?: () => JSX.Element; classNames?: Partial; translations?: Partial; }; @@ -82,8 +86,38 @@ export function generateCarouselId() { return `ais-Carousel-${lastCarouselId++}`; } -export function createCarouselComponent({ createElement }: Renderer) { - return function Carousel( +function PreviousIconDefaultComponent({ + createElement, +}: Pick) { + return ( + + + + ); +} + +function NextIconDefaultComponent({ + createElement, +}: Pick) { + return ( + + + + ); +} + +export function createCarouselComponent({ createElement, Fragment }: Renderer) { + return function Carousel>( userProps: CarouselProps ) { const { @@ -92,7 +126,13 @@ export function createCarouselComponent({ createElement }: Renderer) { previousButtonRef, carouselIdRef, classNames = {}, - itemComponent: ItemComponent, + itemComponent: ItemComponent = createDefaultItemComponent({ + createElement, + Fragment, + }), + previousIconComponent: + PreviousIconComponent = PreviousIconDefaultComponent, + nextIconComponent: NextIconComponent = NextIconDefaultComponent, items, translations: userTranslations, ...props @@ -167,14 +207,7 @@ export function createCarouselComponent({ createElement }: Renderer) { scrollLeft(); }} > - - - +
    - - - + ); diff --git a/packages/instantsearch-ui-components/src/components/__tests__/Carousel.test.tsx b/packages/instantsearch-ui-components/src/components/__tests__/Carousel.test.tsx index 14f3d4c189..9f906e705d 100644 --- a/packages/instantsearch-ui-components/src/components/__tests__/Carousel.test.tsx +++ b/packages/instantsearch-ui-components/src/components/__tests__/Carousel.test.tsx @@ -135,6 +135,83 @@ describe('Carousel', () => { `); }); + test('renders custom "Previous" and "Next" components', () => { + const { container } = render( + Previous} + nextIconComponent={() => Next} + /> + ); + + expect(container).toMatchInlineSnapshot(` +
    + +
    + `); + }); + test('accepts custom translations', () => { const { container } = render( { `); }); + it('does not include the templates', () => { + // @ts-expect-error + expect(() => instantsearch.templates).toThrowErrorMatchingInlineSnapshot(` + "\\"instantsearch.templates\\" are not available from the ES build. + + To import the templates: + + import { carousel } from 'instantsearch.js/es/templates'" + `); + }); + it('includes the helper functions', () => { expect(Object.keys(instantsearch)).toMatchInlineSnapshot(` [ diff --git a/packages/instantsearch.js/src/__tests__/index-test.ts b/packages/instantsearch.js/src/__tests__/index-test.ts index 5a16a04b1f..019391c834 100644 --- a/packages/instantsearch.js/src/__tests__/index-test.ts +++ b/packages/instantsearch.js/src/__tests__/index-test.ts @@ -28,6 +28,7 @@ describe('instantsearch()', () => { "middlewares", "routers", "stateMappings", + "templates", "createInfiniteHitsSessionStorageCache", "highlight", "reverseHighlight", diff --git a/packages/instantsearch.js/src/index.es.ts b/packages/instantsearch.js/src/index.es.ts index 3831f671f8..85fd8ccaad 100644 --- a/packages/instantsearch.js/src/index.es.ts +++ b/packages/instantsearch.js/src/index.es.ts @@ -110,6 +110,18 @@ import { connectSearchBox } from 'instantsearch.js/es/connectors'` }, }); +Object.defineProperty(instantsearch, 'templates', { + get() { + throw new ReferenceError( + `"instantsearch.templates" are not available from the ES build. + +To import the templates: + +import { carousel } from 'instantsearch.js/es/templates'` + ); + }, +}); + export default instantsearch; export * from './types'; diff --git a/packages/instantsearch.js/src/index.ts b/packages/instantsearch.js/src/index.ts index 38919692b6..b99879f2e1 100644 --- a/packages/instantsearch.js/src/index.ts +++ b/packages/instantsearch.js/src/index.ts @@ -6,6 +6,7 @@ import * as routers from './lib/routers/index'; import * as stateMappings from './lib/stateMappings/index'; import version from './lib/version'; import * as middlewares from './middlewares/index'; +import * as templates from './templates/index'; import * as widgets from './widgets/index'; import type { InstantSearchOptions } from './lib/InstantSearch'; @@ -23,6 +24,7 @@ type InstantSearchModule = { routers: typeof routers; stateMappings: typeof stateMappings; + templates: typeof templates; createInfiniteHitsSessionStorageCache: typeof createInfiniteHitsSessionStorageCache; @@ -68,6 +70,7 @@ instantsearch.middlewares = middlewares; instantsearch.routers = routers; instantsearch.stateMappings = stateMappings; +instantsearch.templates = templates; instantsearch.createInfiniteHitsSessionStorageCache = createInfiniteHitsSessionStorageCache; diff --git a/packages/instantsearch.js/src/templates/__tests__/carousel.test.tsx b/packages/instantsearch.js/src/templates/__tests__/carousel.test.tsx new file mode 100644 index 0000000000..7e05329aaf --- /dev/null +++ b/packages/instantsearch.js/src/templates/__tests__/carousel.test.tsx @@ -0,0 +1,227 @@ +/** + * @jest-environment jsdom + */ +/** @jsx h */ + +import { render } from '@testing-library/preact'; +import { h } from 'preact'; + +import { carousel } from '../carousel/carousel'; + +describe('carousel', () => { + test('renders with default options', () => { + const template = carousel(); + + const { container } = render( + template({ + items: [ + { objectID: '1', __position: 1 }, + { objectID: '2', __position: 2 }, + ], + templates: { + item({ item }) { + return

    {item.objectID}

    ; + }, + }, + }) + ); + + expect(container).toMatchInlineSnapshot(` +
    + +
    + `); + }); + + test('adds custom CSS classes', () => { + const template = carousel({ + cssClasses: { + root: 'ROOT', + list: 'LIST', + item: 'ITEM', + navigation: 'NAVIGATION', + navigationNext: 'NAVIGATION_NEXT', + navigationPrevious: 'NAVIGATION_PREVIOUS', + }, + }); + + const { container } = render( + template({ + items: [ + { objectID: '1', __position: 1 }, + { objectID: '2', __position: 2 }, + ], + templates: { + item({ item }) { + return

    {item}

    ; + }, + }, + }) + ); + + expect(container.querySelector('.ais-Carousel')).toHaveClass('ROOT'); + expect(container.querySelector('.ais-Carousel-list')).toHaveClass('LIST'); + expect(container.querySelector('.ais-Carousel-item')).toHaveClass('ITEM'); + expect( + container.querySelector('.ais-Carousel-navigation--previous') + ).toHaveClass('NAVIGATION', 'NAVIGATION_PREVIOUS'); + expect( + container.querySelector('.ais-Carousel-navigation--next') + ).toHaveClass('NAVIGATION', 'NAVIGATION_NEXT'); + }); + + test('adds custom templates', () => { + const template = carousel({ + templates: { + previous() { + return

    Previous

    ; + }, + next() { + return

    Next

    ; + }, + }, + }); + + const { container } = render( + template({ + items: [ + { objectID: '1', __position: 1 }, + { objectID: '2', __position: 2 }, + ], + templates: { + item({ item }) { + return

    {item.objectID}

    ; + }, + }, + }) + ); + + expect(container).toMatchInlineSnapshot(` +
    + +
    + `); + }); +}); diff --git a/packages/instantsearch.js/src/templates/carousel/carousel.tsx b/packages/instantsearch.js/src/templates/carousel/carousel.tsx new file mode 100644 index 0000000000..1d1b449c19 --- /dev/null +++ b/packages/instantsearch.js/src/templates/carousel/carousel.tsx @@ -0,0 +1,87 @@ +/** @jsx h */ + +import { html } from 'htm/preact'; +import { + createCarouselComponent, + generateCarouselId, +} from 'instantsearch-ui-components'; +import { Fragment, h } from 'preact'; +import { useRef } from 'preact/hooks'; + +import type { + CarouselProps as CarouselUiProps, + VNode, +} from 'instantsearch-ui-components'; + +const Carousel = createCarouselComponent({ + createElement: h, + Fragment, +}); + +function CarouselWithRefs>( + props: Omit< + CarouselUiProps, + 'listRef' | 'nextButtonRef' | 'previousButtonRef' | 'carouselIdRef' + > +) { + const carouselRefs: Pick< + CarouselUiProps, + 'listRef' | 'nextButtonRef' | 'previousButtonRef' | 'carouselIdRef' + > = { + listRef: useRef(null), + nextButtonRef: useRef(null), + previousButtonRef: useRef(null), + carouselIdRef: useRef(generateCarouselId()), + }; + + return ; +} + +type Template = (params: { html: typeof html }) => VNode | VNode[] | null; + +type CreateCarouselTemplateProps> = { + templates?: Partial<{ + previous: Exclude; + next: Exclude; + }>; + cssClasses?: Partial['classNames']>; +}; + +type CarouselTemplateProps> = Pick< + CarouselUiProps, + 'items' +> & { + templates: { + item?: CarouselUiProps['itemComponent']; + }; +}; + +export function carousel>({ + cssClasses, + templates = {}, +}: CreateCarouselTemplateProps = {}) { + return function CarouselTemplate({ + items, + templates: userTemplates, + }: CarouselTemplateProps) { + const { previous, next } = templates; + + return ( + previous({ html }) + : undefined) as CarouselUiProps['previousIconComponent'] + } + nextIconComponent={ + (next + ? () => next({ html }) + : undefined) as CarouselUiProps['nextIconComponent'] + } + classNames={cssClasses} + /> + ); + }; +} diff --git a/packages/instantsearch.js/src/templates/index.ts b/packages/instantsearch.js/src/templates/index.ts new file mode 100644 index 0000000000..7521a891e1 --- /dev/null +++ b/packages/instantsearch.js/src/templates/index.ts @@ -0,0 +1 @@ +export * from './carousel/carousel'; diff --git a/packages/instantsearch.js/src/widgets/frequently-bought-together/__tests__/frequently-bought-together.test.tsx b/packages/instantsearch.js/src/widgets/frequently-bought-together/__tests__/frequently-bought-together.test.tsx index 2fcd17e94b..750524efde 100644 --- a/packages/instantsearch.js/src/widgets/frequently-bought-together/__tests__/frequently-bought-together.test.tsx +++ b/packages/instantsearch.js/src/widgets/frequently-bought-together/__tests__/frequently-bought-together.test.tsx @@ -7,6 +7,7 @@ import { wait } from '@instantsearch/testutils'; import { h } from 'preact'; import instantsearch from '../../../index.es'; +import { carousel } from '../../../templates'; import frequentlyBoughtTogether from '../frequently-bought-together'; beforeEach(() => { @@ -258,6 +259,162 @@ describe('frequentlyBoughtTogether', () => { `); }); + test('renders with a custom layout using `html`', async () => { + const container = document.createElement('div'); + const searchClient = createRecommendSearchClient(); + const options: Parameters[0] = { + container, + objectIDs: ['1'], + templates: { + layout({ items }, { html }) { + return html`
      + ${items.map((item) => html`
    • ${item.objectID}

    • `)} +
    `; + }, + }, + }; + + const search = instantsearch({ indexName: 'indexName', searchClient }); + const widget = frequentlyBoughtTogether(options); + + search.addWidgets([widget]); + search.start(); + + await wait(0); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Frequently bought together +

    +
      +
    • +

      + 1 +

      +
    • +
    • +

      + 2 +

      +
    • +
    +
    +
    + `); + }); + + test('renders with a carousel layout using `html`', async () => { + const container = document.createElement('div'); + const searchClient = createRecommendSearchClient(); + const options: Parameters[0] = { + container, + objectIDs: ['1'], + templates: { + item(hit, { html }) { + return html`

    ${hit.objectID}

    `; + }, + layout: carousel({ + cssClasses: { + root: 'ROOT', + list: 'LIST', + item: 'ITEM', + navigation: 'NAVIGATION', + navigationNext: 'NAVIGATION_NEXT', + navigationPrevious: 'NAVIGATION_PREVIOUS', + }, + templates: { + previous({ html }) { + return html`

    Previous

    `; + }, + next({ html }) { + return html`

    Next

    `; + }, + }, + }), + }, + }; + + const search = instantsearch({ indexName: 'indexName', searchClient }); + const widget = frequentlyBoughtTogether(options); + + search.addWidgets([widget]); + search.start(); + + await wait(0); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Frequently bought together +

    + +
    +
    + `); + }); + test('renders with templates using JSX', async () => { const container = document.createElement('div'); const searchClient = createRecommendSearchClient(); @@ -348,5 +505,167 @@ describe('frequentlyBoughtTogether', () => { `); }); + + test('renders with a custom layout using JSX', async () => { + const container = document.createElement('div'); + const searchClient = createRecommendSearchClient(); + const options: Parameters[0] = { + container, + objectIDs: ['1'], + templates: { + layout({ items }) { + return ( +
      + {items.map((item) => ( +
    • +

      {item.objectID}

      +
    • + ))} +
    + ); + }, + }, + }; + + const search = instantsearch({ indexName: 'indexName', searchClient }); + const widget = frequentlyBoughtTogether(options); + + search.addWidgets([widget]); + search.start(); + + await wait(0); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Frequently bought together +

    +
      +
    • +

      + 1 +

      +
    • +
    • +

      + 2 +

      +
    • +
    +
    +
    + `); + }); + + test('renders with a carousel layout using JSX', async () => { + const container = document.createElement('div'); + const searchClient = createRecommendSearchClient(); + const options: Parameters[0] = { + container, + objectIDs: ['1'], + templates: { + item(hit) { + return

    {hit.objectID}

    ; + }, + layout: carousel({ + cssClasses: { + root: 'ROOT', + list: 'LIST', + item: 'ITEM', + navigation: 'NAVIGATION', + navigationNext: 'NAVIGATION_NEXT', + navigationPrevious: 'NAVIGATION_PREVIOUS', + }, + templates: { + previous() { + return

    Previous

    ; + }, + next() { + return

    Next

    ; + }, + }, + }), + }, + }; + + const search = instantsearch({ indexName: 'indexName', searchClient }); + const widget = frequentlyBoughtTogether(options); + + search.addWidgets([widget]); + search.start(); + + await wait(0); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Frequently bought together +

    + +
    +
    + `); + }); }); }); diff --git a/packages/instantsearch.js/src/widgets/frequently-bought-together/frequently-bought-together.tsx b/packages/instantsearch.js/src/widgets/frequently-bought-together/frequently-bought-together.tsx index ee8e132d84..b95ed267bc 100644 --- a/packages/instantsearch.js/src/widgets/frequently-bought-together/frequently-bought-together.tsx +++ b/packages/instantsearch.js/src/widgets/frequently-bought-together/frequently-bought-together.tsx @@ -111,6 +111,33 @@ const renderer = : undefined ) as FrequentlyBoughtTogetherUiProps['emptyComponent']; + const layoutComponent = ( + templates.layout + ? (data) => ( + }) => ( + + ) + : undefined, + }, + }} + /> + ) + : undefined + ) as FrequentlyBoughtTogetherUiProps['view']; + render( {}} classNames={cssClasses} emptyComponent={emptyComponent} + view={layoutComponent} status={instantSearchInstance.status} />, containerNode @@ -153,6 +181,22 @@ export type FrequentlyBoughtTogetherTemplates< * Template to use for each result. This template will receive an object containing a single record. */ item: Template>; + + /** + * Template to use to wrap all items. + */ + layout: Template< + Pick< + Parameters< + NonNullable>['view']> + >[0], + 'items' + > & { + templates: { + item: FrequentlyBoughtTogetherUiProps['itemComponent']; + }; + } + >; }>; type FrequentlyBoughtTogetherWidgetParams< diff --git a/packages/instantsearch.js/src/widgets/looking-similar/__tests__/looking-similar.test.tsx b/packages/instantsearch.js/src/widgets/looking-similar/__tests__/looking-similar.test.tsx index 63bf564560..93fce1d5f1 100644 --- a/packages/instantsearch.js/src/widgets/looking-similar/__tests__/looking-similar.test.tsx +++ b/packages/instantsearch.js/src/widgets/looking-similar/__tests__/looking-similar.test.tsx @@ -7,6 +7,7 @@ import { wait } from '@instantsearch/testutils'; import { h } from 'preact'; import instantsearch from '../../../index.es'; +import { carousel } from '../../../templates'; import lookingSimilar from '../looking-similar'; beforeEach(() => { @@ -259,6 +260,162 @@ describe('lookingSimilar', () => { `); }); + test('renders with a custom layout using `html`', async () => { + const container = document.createElement('div'); + const searchClient = createRecommendSearchClient(); + const options: Parameters[0] = { + container, + objectIDs: ['1'], + templates: { + layout({ items }, { html }) { + return html`
      + ${items.map((item) => html`
    • ${item.objectID}

    • `)} +
    `; + }, + }, + }; + + const search = instantsearch({ indexName: 'indexName', searchClient }); + const widget = lookingSimilar(options); + + search.addWidgets([widget]); + search.start(); + + await wait(0); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Looking similar +

    +
      +
    • +

      + 1 +

      +
    • +
    • +

      + 2 +

      +
    • +
    +
    +
    + `); + }); + + test('renders with a carousel layout using `html`', async () => { + const container = document.createElement('div'); + const searchClient = createRecommendSearchClient(); + const options: Parameters[0] = { + container, + objectIDs: ['1'], + templates: { + item(hit, { html }) { + return html`

    ${hit.objectID}

    `; + }, + layout: carousel({ + cssClasses: { + root: 'ROOT', + list: 'LIST', + item: 'ITEM', + navigation: 'NAVIGATION', + navigationNext: 'NAVIGATION_NEXT', + navigationPrevious: 'NAVIGATION_PREVIOUS', + }, + templates: { + previous({ html }) { + return html`

    Previous

    `; + }, + next({ html }) { + return html`

    Next

    `; + }, + }, + }), + }, + }; + + const search = instantsearch({ indexName: 'indexName', searchClient }); + const widget = lookingSimilar(options); + + search.addWidgets([widget]); + search.start(); + + await wait(0); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Looking similar +

    + +
    +
    + `); + }); + test('renders with templates using JSX', async () => { const container = document.createElement('div'); const searchClient = createRecommendSearchClient(); @@ -349,5 +506,167 @@ describe('lookingSimilar', () => { `); }); + + test('renders with a custom layout using JSX', async () => { + const container = document.createElement('div'); + const searchClient = createRecommendSearchClient(); + const options: Parameters[0] = { + container, + objectIDs: ['1'], + templates: { + layout({ items }) { + return ( +
      + {items.map((item) => ( +
    • +

      {item.objectID}

      +
    • + ))} +
    + ); + }, + }, + }; + + const search = instantsearch({ indexName: 'indexName', searchClient }); + const widget = lookingSimilar(options); + + search.addWidgets([widget]); + search.start(); + + await wait(0); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Looking similar +

    +
      +
    • +

      + 1 +

      +
    • +
    • +

      + 2 +

      +
    • +
    +
    +
    + `); + }); + + test('renders with a carousel layout using JSX', async () => { + const container = document.createElement('div'); + const searchClient = createRecommendSearchClient(); + const options: Parameters[0] = { + container, + objectIDs: ['1'], + templates: { + item(hit) { + return

    {hit.objectID}

    ; + }, + layout: carousel({ + cssClasses: { + root: 'ROOT', + list: 'LIST', + item: 'ITEM', + navigation: 'NAVIGATION', + navigationNext: 'NAVIGATION_NEXT', + navigationPrevious: 'NAVIGATION_PREVIOUS', + }, + templates: { + previous() { + return

    Previous

    ; + }, + next() { + return

    Next

    ; + }, + }, + }), + }, + }; + + const search = instantsearch({ indexName: 'indexName', searchClient }); + const widget = lookingSimilar(options); + + search.addWidgets([widget]); + search.start(); + + await wait(0); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Looking similar +

    + +
    +
    + `); + }); }); }); diff --git a/packages/instantsearch.js/src/widgets/looking-similar/looking-similar.tsx b/packages/instantsearch.js/src/widgets/looking-similar/looking-similar.tsx index c71ee1bc1a..d51b797503 100644 --- a/packages/instantsearch.js/src/widgets/looking-similar/looking-similar.tsx +++ b/packages/instantsearch.js/src/widgets/looking-similar/looking-similar.tsx @@ -107,6 +107,33 @@ function createRenderer = BaseHit>({ : undefined ) as LookingSimilarUiProps['emptyComponent']; + const layoutComponent = ( + templates.layout + ? (data) => ( + }) => ( + + ) + : undefined, + }, + }} + /> + ) + : undefined + ) as LookingSimilarUiProps['view']; + render( = BaseHit>({ sendEvent={() => {}} classNames={cssClasses} emptyComponent={emptyComponent} + view={layoutComponent} status={instantSearchInstance.status} />, containerNode @@ -148,6 +176,20 @@ export type LookingSimilarTemplates< * Template to use for each result. This template will receive an object containing a single record. */ item: Template>; + + /** + * Template to use to wrap all items. + */ + layout: Template< + Pick< + Parameters>['view']>>[0], + 'items' + > & { + templates: { + item: LookingSimilarUiProps['itemComponent']; + }; + } + >; }>; type LookingSimilarWidgetParams = BaseHit> = { diff --git a/packages/instantsearch.js/src/widgets/related-products/__tests__/related-products.test.tsx b/packages/instantsearch.js/src/widgets/related-products/__tests__/related-products.test.tsx index 20d06671c8..fc52021625 100644 --- a/packages/instantsearch.js/src/widgets/related-products/__tests__/related-products.test.tsx +++ b/packages/instantsearch.js/src/widgets/related-products/__tests__/related-products.test.tsx @@ -7,6 +7,7 @@ import { wait } from '@instantsearch/testutils/wait'; import { h } from 'preact'; import instantsearch from '../../../index.es'; +import { carousel } from '../../../templates'; import relatedProducts from '../related-products'; beforeEach(() => { @@ -258,6 +259,162 @@ describe('relatedProducts', () => { `); }); + test('renders with a custom layout using `html`', async () => { + const container = document.createElement('div'); + const searchClient = createRecommendSearchClient(); + const options: Parameters[0] = { + container, + objectIDs: ['1'], + templates: { + layout({ items }, { html }) { + return html`
      + ${items.map((item) => html`
    • ${item.objectID}

    • `)} +
    `; + }, + }, + }; + + const search = instantsearch({ indexName: 'indexName', searchClient }); + const widget = relatedProducts(options); + + search.addWidgets([widget]); + search.start(); + + await wait(0); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Related products +

    +
      +
    • +

      + 1 +

      +
    • +
    • +

      + 2 +

      +
    • +
    +
    +
    + `); + }); + + test('renders with a carousel layout using `html`', async () => { + const container = document.createElement('div'); + const searchClient = createRecommendSearchClient(); + const options: Parameters[0] = { + container, + objectIDs: ['1'], + templates: { + item(hit, { html }) { + return html`

    ${hit.objectID}

    `; + }, + layout: carousel({ + cssClasses: { + root: 'ROOT', + list: 'LIST', + item: 'ITEM', + navigation: 'NAVIGATION', + navigationNext: 'NAVIGATION_NEXT', + navigationPrevious: 'NAVIGATION_PREVIOUS', + }, + templates: { + previous({ html }) { + return html`

    Previous

    `; + }, + next({ html }) { + return html`

    Next

    `; + }, + }, + }), + }, + }; + + const search = instantsearch({ indexName: 'indexName', searchClient }); + const widget = relatedProducts(options); + + search.addWidgets([widget]); + search.start(); + + await wait(0); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Related products +

    + +
    +
    + `); + }); + test('renders with templates using JSX', async () => { const container = document.createElement('div'); const searchClient = createRecommendSearchClient(); @@ -346,5 +503,167 @@ describe('relatedProducts', () => { `); }); + + test('renders with a custom layout using JSX', async () => { + const container = document.createElement('div'); + const searchClient = createRecommendSearchClient(); + const options: Parameters[0] = { + container, + objectIDs: ['1'], + templates: { + layout({ items }) { + return ( +
      + {items.map((item) => ( +
    • +

      {item.objectID}

      +
    • + ))} +
    + ); + }, + }, + }; + + const search = instantsearch({ indexName: 'indexName', searchClient }); + const widget = relatedProducts(options); + + search.addWidgets([widget]); + search.start(); + + await wait(0); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Related products +

    +
      +
    • +

      + 1 +

      +
    • +
    • +

      + 2 +

      +
    • +
    +
    +
    + `); + }); + + test('renders with a carousel layout using JSX', async () => { + const container = document.createElement('div'); + const searchClient = createRecommendSearchClient(); + const options: Parameters[0] = { + container, + objectIDs: ['1'], + templates: { + item(hit) { + return

    {hit.objectID}

    ; + }, + layout: carousel({ + cssClasses: { + root: 'ROOT', + list: 'LIST', + item: 'ITEM', + navigation: 'NAVIGATION', + navigationNext: 'NAVIGATION_NEXT', + navigationPrevious: 'NAVIGATION_PREVIOUS', + }, + templates: { + previous() { + return

    Previous

    ; + }, + next() { + return

    Next

    ; + }, + }, + }), + }, + }; + + const search = instantsearch({ indexName: 'indexName', searchClient }); + const widget = relatedProducts(options); + + search.addWidgets([widget]); + search.start(); + + await wait(0); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Related products +

    + +
    +
    + `); + }); }); }); diff --git a/packages/instantsearch.js/src/widgets/related-products/related-products.tsx b/packages/instantsearch.js/src/widgets/related-products/related-products.tsx index 55359282b4..4b2a865314 100644 --- a/packages/instantsearch.js/src/widgets/related-products/related-products.tsx +++ b/packages/instantsearch.js/src/widgets/related-products/related-products.tsx @@ -117,6 +117,33 @@ function createRenderer = BaseHit>({ : undefined ) as RelatedProductsUiProps['emptyComponent']; + const layoutComponent = ( + templates.layout + ? (data) => ( + }) => ( + + ) + : undefined, + }, + }} + /> + ) + : undefined + ) as RelatedProductsUiProps['view']; + render( = BaseHit>({ headerComponent={headerComponent} itemComponent={itemComponent} emptyComponent={emptyComponent} + view={layoutComponent} status={instantSearchInstance.status} />, containerNode @@ -158,6 +186,20 @@ export type RelatedProductsTemplates< * Template to use for each result. This template will receive an object containing a single record. */ item: Template>; + + /** + * Template to use to wrap all items. + */ + layout: Template< + Pick< + Parameters>['view']>>[0], + 'items' + > & { + templates: { + item: RelatedProductsUiProps['itemComponent']; + }; + } + >; }>; type RelatedProductsWidgetParams = BaseHit> = { diff --git a/packages/instantsearch.js/src/widgets/trending-items/__tests__/trending-items.test.tsx b/packages/instantsearch.js/src/widgets/trending-items/__tests__/trending-items.test.tsx index 9eaf1543d4..ed94aae15f 100644 --- a/packages/instantsearch.js/src/widgets/trending-items/__tests__/trending-items.test.tsx +++ b/packages/instantsearch.js/src/widgets/trending-items/__tests__/trending-items.test.tsx @@ -7,6 +7,7 @@ import { wait } from '@instantsearch/testutils/wait'; import { h } from 'preact'; import instantsearch from '../../../index.es'; +import { carousel } from '../../../templates'; import trendingItems from '../trending-items'; beforeEach(() => { @@ -253,6 +254,160 @@ describe('trendingItems', () => { `); }); + test('renders with a custom layout using `html`', async () => { + const container = document.createElement('div'); + const searchClient = createRecommendSearchClient(); + const options: Parameters[0] = { + container, + templates: { + layout({ items }, { html }) { + return html`
      + ${items.map((item) => html`
    • ${item.objectID}

    • `)} +
    `; + }, + }, + }; + + const search = instantsearch({ indexName: 'indexName', searchClient }); + const widget = trendingItems(options); + + search.addWidgets([widget]); + search.start(); + + await wait(0); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Trending items +

    +
      +
    • +

      + 1 +

      +
    • +
    • +

      + 2 +

      +
    • +
    +
    +
    + `); + }); + + test('renders with a carousel layout using `html`', async () => { + const container = document.createElement('div'); + const searchClient = createRecommendSearchClient(); + const options: Parameters[0] = { + container, + templates: { + item(hit, { html }) { + return html`

    ${hit.objectID}

    `; + }, + layout: carousel({ + cssClasses: { + root: 'ROOT', + list: 'LIST', + item: 'ITEM', + navigation: 'NAVIGATION', + navigationNext: 'NAVIGATION_NEXT', + navigationPrevious: 'NAVIGATION_PREVIOUS', + }, + templates: { + previous({ html }) { + return html`

    Previous

    `; + }, + next({ html }) { + return html`

    Next

    `; + }, + }, + }), + }, + }; + + const search = instantsearch({ indexName: 'indexName', searchClient }); + const widget = trendingItems(options); + + search.addWidgets([widget]); + search.start(); + + await wait(0); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Trending items +

    + +
    +
    + `); + }); + test('renders with templates using JSX', async () => { const container = document.createElement('div'); const searchClient = createRecommendSearchClient(); @@ -340,5 +495,165 @@ describe('trendingItems', () => { `); }); + + test('renders with a custom layout using JSX', async () => { + const container = document.createElement('div'); + const searchClient = createRecommendSearchClient(); + const options: Parameters[0] = { + container, + templates: { + layout({ items }) { + return ( +
      + {items.map((item) => ( +
    • +

      {item.objectID}

      +
    • + ))} +
    + ); + }, + }, + }; + + const search = instantsearch({ indexName: 'indexName', searchClient }); + const widget = trendingItems(options); + + search.addWidgets([widget]); + search.start(); + + await wait(0); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Trending items +

    +
      +
    • +

      + 1 +

      +
    • +
    • +

      + 2 +

      +
    • +
    +
    +
    + `); + }); + + test('renders with a carousel layout using JSX', async () => { + const container = document.createElement('div'); + const searchClient = createRecommendSearchClient(); + const options: Parameters[0] = { + container, + templates: { + item(hit) { + return

    {hit.objectID}

    ; + }, + layout: carousel({ + cssClasses: { + root: 'ROOT', + list: 'LIST', + item: 'ITEM', + navigation: 'NAVIGATION', + navigationNext: 'NAVIGATION_NEXT', + navigationPrevious: 'NAVIGATION_PREVIOUS', + }, + templates: { + previous() { + return

    Previous

    ; + }, + next() { + return

    Next

    ; + }, + }, + }), + }, + }; + + const search = instantsearch({ indexName: 'indexName', searchClient }); + const widget = trendingItems(options); + + search.addWidgets([widget]); + search.start(); + + await wait(0); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Trending items +

    + +
    +
    + `); + }); }); }); diff --git a/packages/instantsearch.js/src/widgets/trending-items/trending-items.tsx b/packages/instantsearch.js/src/widgets/trending-items/trending-items.tsx index 842f3787e6..af0ff19640 100644 --- a/packages/instantsearch.js/src/widgets/trending-items/trending-items.tsx +++ b/packages/instantsearch.js/src/widgets/trending-items/trending-items.tsx @@ -117,6 +117,33 @@ function createRenderer = BaseHit>({ : undefined ) as TrendingItemsUiProps['emptyComponent']; + const layoutComponent = ( + templates.layout + ? (data) => ( + }) => ( + + ) + : undefined, + }, + }} + /> + ) + : undefined + ) as TrendingItemsUiProps['view']; + render( = BaseHit>({ headerComponent={headerComponent} itemComponent={itemComponent} emptyComponent={emptyComponent} + view={layoutComponent} status={instantSearchInstance.status} />, containerNode @@ -157,6 +185,20 @@ export type TrendingItemsTemplates = BaseHit> = * Template to use for each result. This template will receive an object containing a single record. */ item: Template>; + + /** + * Template to use to wrap all items. + */ + layout: Template< + Pick< + Parameters>['view']>>[0], + 'items' + > & { + templates: { + item: TrendingItemsUiProps['itemComponent']; + }; + } + >; }>; type TrendingItemsWidgetParams = BaseHit> = {