diff --git a/bundlesize.config.json b/bundlesize.config.json index 209bf90bc..2107fb1d5 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -6,7 +6,7 @@ }, { "path": "packages/autocomplete-js/dist/umd/index.production.js", - "maxSize": "16.75 kB" + "maxSize": "17.5 kB" }, { "path": "packages/autocomplete-preset-algolia/dist/umd/index.production.js", diff --git a/examples/html-templates/README.md b/examples/html-templates/README.md new file mode 100644 index 000000000..6e74b954f --- /dev/null +++ b/examples/html-templates/README.md @@ -0,0 +1,34 @@ +# Autocomplete with HTML templates example + +This example shows how to use HTML templates in Autocomplete. + +

A capture of the Autocomplete with HTML templates example

+ +## Demo + +[Access the demo](https://codesandbox.io/s/github/algolia/autocomplete/tree/next/examples/html-templates) + +## How to run this example locally + +### 1. Clone this repository + +```sh +git clone git@github.com:algolia/autocomplete.git +``` + +### 2. Install the dependencies and run the server + +```sh +yarn +yarn workspace @algolia/autocomplete-example-html-templates start +``` + +Alternatively, you may use npm: + +```sh +cd examples/html-templates +npm install +npm start +``` + +Open to see your app. diff --git a/examples/html-templates/app.js b/examples/html-templates/app.js new file mode 100644 index 000000000..7c79d68ce --- /dev/null +++ b/examples/html-templates/app.js @@ -0,0 +1,65 @@ +import { autocomplete, getAlgoliaResults } from '@algolia/autocomplete-js'; +import algoliasearch from 'algoliasearch/lite'; + +import '@algolia/autocomplete-theme-classic'; + +const appId = 'latency'; +const apiKey = '6be0576ff61c053d5f9a3225e2a90f76'; +const searchClient = algoliasearch(appId, apiKey); + +autocomplete({ + container: '#autocomplete', + placeholder: 'Search', + getSources({ query }) { + return [ + { + sourceId: 'products', + getItems() { + return getAlgoliaResults({ + searchClient, + queries: [ + { + indexName: 'instant_search', + query, + }, + ], + }); + }, + templates: { + item({ item, components, html }) { + return html`
+
+
+ ${item.name} +
+ +
+
+ ${components.Highlight({ hit: item, attribute: 'name' })} +
+
+ By ${item.brand} in ${' '} + ${item.categories[0]} +
+
+
+
`; + }, + }, + }, + ]; + }, + render({ children, render, html }, root) { + render(html`
${children}
`, root); + }, + renderNoResults({ children, render, html }, root) { + render(html`
${children}
`, root); + }, +}); diff --git a/examples/html-templates/capture.png b/examples/html-templates/capture.png new file mode 100644 index 000000000..0b8830e1c Binary files /dev/null and b/examples/html-templates/capture.png differ diff --git a/examples/html-templates/env.js b/examples/html-templates/env.js new file mode 100644 index 000000000..3811e898d --- /dev/null +++ b/examples/html-templates/env.js @@ -0,0 +1,6 @@ +// Parcel picks the `source` field of the monorepo packages and thus doesn't +// apply the Babel config. We therefore need to manually override the constants +// in the app. +// See https://twitter.com/devongovett/status/1134231234605830144 +global.__DEV__ = process.env.NODE_ENV !== 'production'; +global.__TEST__ = false; diff --git a/examples/html-templates/favicon.png b/examples/html-templates/favicon.png new file mode 100644 index 000000000..084fdfdfc Binary files /dev/null and b/examples/html-templates/favicon.png differ diff --git a/examples/html-templates/index.html b/examples/html-templates/index.html new file mode 100644 index 000000000..ab32ef63b --- /dev/null +++ b/examples/html-templates/index.html @@ -0,0 +1,20 @@ + + + + + + + + + HTML Templates | Autocomplete + + + +
+
+
+ + + + + diff --git a/examples/html-templates/package.json b/examples/html-templates/package.json new file mode 100644 index 000000000..7ca593f4e --- /dev/null +++ b/examples/html-templates/package.json @@ -0,0 +1,25 @@ +{ + "name": "@algolia/autocomplete-example-html-templates", + "description": "Autocomplete example with HTML templates", + "version": "1.5.7", + "private": true, + "license": "MIT", + "scripts": { + "build": "parcel build index.html", + "start": "parcel index.html" + }, + "dependencies": { + "@algolia/autocomplete-js": "1.5.7", + "@algolia/autocomplete-theme-classic": "1.5.7", + "algoliasearch": "4.9.1" + }, + "devDependencies": { + "@algolia/client-search": "4.9.1", + "parcel": "2.0.0-beta.2" + }, + "keywords": [ + "algolia", + "autocomplete", + "javascript" + ] +} diff --git a/examples/html-templates/style.css b/examples/html-templates/style.css new file mode 100644 index 000000000..a4d3906cf --- /dev/null +++ b/examples/html-templates/style.css @@ -0,0 +1,20 @@ +* { + box-sizing: border-box; +} + +body { + background-color: rgb(244, 244, 249); + color: rgb(65, 65, 65); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + padding: 1rem; +} + +.container { + margin: 0 auto; + max-width: 640px; + width: 100%; +} diff --git a/packages/autocomplete-js/package.json b/packages/autocomplete-js/package.json index 46e1234f3..d8c66a7aa 100644 --- a/packages/autocomplete-js/package.json +++ b/packages/autocomplete-js/package.json @@ -34,7 +34,8 @@ "@algolia/autocomplete-core": "1.5.7", "@algolia/autocomplete-preset-algolia": "1.5.7", "@algolia/autocomplete-shared": "1.5.7", - "preact": "^10.0.0" + "preact": "^10.0.0", + "htm": "^3.0.0" }, "devDependencies": { "@algolia/client-search": "4.9.1" diff --git a/packages/autocomplete-js/src/__tests__/api.test.ts b/packages/autocomplete-js/src/__tests__/api.test.ts index cbf0c05a7..b68d8dd9e 100644 --- a/packages/autocomplete-js/src/__tests__/api.test.ts +++ b/packages/autocomplete-js/src/__tests__/api.test.ts @@ -1,5 +1,9 @@ import { createAutocomplete } from '@algolia/autocomplete-core'; -import { waitFor } from '@testing-library/dom'; +import { fireEvent, waitFor } from '@testing-library/dom'; +import { + createElement as preactCreateElement, + render as preactRender, +} from 'preact'; import { createCollection } from '../../../../test/utils'; import { autocomplete } from '../autocomplete'; @@ -371,6 +375,76 @@ describe('api', () => { document.querySelector('.aa-SubmitButton') ).toHaveAttribute('title', 'Envoyer'); }); + + test('updates the renderer', async () => { + const container = document.createElement('div'); + const panelContainer = document.createElement('div'); + document.body.appendChild(container); + document.body.appendChild(panelContainer); + + const CustomFragment = (props: any) => props.children; + const mockCreateElement1 = jest.fn(preactCreateElement); + const mockCreateElement2 = jest.fn(preactCreateElement); + const mockRender = jest.fn().mockImplementation(preactRender); + + const { update } = autocomplete<{ label: string }>({ + container, + panelContainer, + getSources() { + return [ + { + sourceId: 'testSource', + getItems({ query }) { + return [{ label: query }]; + }, + templates: { + item({ item, html }) { + return html`
${item.label}
`; + }, + }, + }, + ]; + }, + renderer: { + Fragment: CustomFragment, + render: mockRender, + createElement: mockCreateElement1, + }, + }); + + const input = container.querySelector('.aa-Input'); + + fireEvent.input(input, { target: { value: 'apple' } }); + + await waitFor(() => { + expect( + panelContainer.querySelector('.aa-Panel') + ).toHaveTextContent('apple'); + expect(mockCreateElement1).toHaveBeenCalled(); + }); + + mockCreateElement1.mockClear(); + + update({ + renderer: { + Fragment: CustomFragment, + render: mockRender, + createElement: mockCreateElement2, + }, + }); + + fireEvent.input(input, { target: { value: 'iphone' } }); + + await waitFor(() => { + expect( + panelContainer.querySelector('.aa-Panel') + ).toHaveTextContent('iphone'); + // The `createElement` function was updated, so the previous + // implementation should no longer be called. + expect(mockCreateElement1).not.toHaveBeenCalled(); + expect(mockCreateElement2).toHaveBeenCalled(); + }); + }); }); describe('destroy', () => { diff --git a/packages/autocomplete-js/src/__tests__/render.test.ts b/packages/autocomplete-js/src/__tests__/render.test.ts index e3b86667d..19314c3c8 100644 --- a/packages/autocomplete-js/src/__tests__/render.test.ts +++ b/packages/autocomplete-js/src/__tests__/render.test.ts @@ -3,7 +3,7 @@ import { createElement as preactCreateElement, Fragment, Fragment as PreactFragment, - render, + render as preactRender, } from 'preact'; import { autocomplete } from '../autocomplete'; @@ -80,7 +80,7 @@ describe('render', () => { ]; }, render({ createElement }, root) { - render(createElement('div', null, 'testSource'), root); + preactRender(createElement('div', null, 'testSource'), root); }, }); @@ -126,7 +126,7 @@ describe('render', () => { expect(panelContainer.querySelector('.aa-Panel')).toBe( root ); - render(createElement('div', null, 'testSource'), root); + preactRender(createElement('div', null, 'testSource'), root); }, }); }); @@ -168,7 +168,7 @@ describe('render', () => { completion: null, }); - render(createElement('div', null, 'testSource'), root); + preactRender(createElement('div', null, 'testSource'), root); }, }); @@ -238,7 +238,7 @@ describe('render', () => { }), }); - render(createElement('div', null, 'testSource'), root); + preactRender(createElement('div', null, 'testSource'), root); }, }); @@ -308,7 +308,7 @@ describe('render', () => { }), ]); - render(createElement('div', null, 'testSource'), root); + preactRender(createElement('div', null, 'testSource'), root); }, }); @@ -379,7 +379,7 @@ describe('render', () => { }, }) ); - render(createElement('div', null, 'testSource'), root); + preactRender(createElement('div', null, 'testSource'), root); }, }); @@ -422,7 +422,7 @@ describe('render', () => { }, render({ createElement }, root) { expect(createElement).toBe(preactCreateElement); - render(createElement('div', null, 'testSource'), root); + preactRender(createElement('div', null, 'testSource'), root); }, }); }); @@ -455,7 +455,73 @@ describe('render', () => { }, render({ createElement, Fragment }, root) { expect(Fragment).toBe(PreactFragment); - render(createElement(Fragment, null, 'testSource'), root); + preactRender(createElement(Fragment, null, 'testSource'), root); + }, + }); + }); + + test('provides a default `render`', () => { + const container = document.createElement('div'); + const panelContainer = document.createElement('div'); + + document.body.appendChild(panelContainer); + autocomplete<{ label: string }>({ + container, + panelContainer, + initialState: { + isOpen: true, + }, + getSources() { + return [ + { + sourceId: 'testSource', + getItems() { + return [{ label: '1' }]; + }, + templates: { + item({ item }) { + return item.label; + }, + }, + }, + ]; + }, + render({ render }, root) { + expect(render).toBe(preactRender); + render(null, root); + }, + }); + }); + + test('provides an `html` function', () => { + const container = document.createElement('div'); + const panelContainer = document.createElement('div'); + + document.body.appendChild(panelContainer); + autocomplete<{ label: string }>({ + container, + panelContainer, + initialState: { + isOpen: true, + }, + getSources() { + return [ + { + sourceId: 'testSource', + getItems() { + return [{ label: '1' }]; + }, + templates: { + item({ item }) { + return item.label; + }, + }, + }, + ]; + }, + render({ children, render, html }, root) { + expect(html).toBeDefined(); + render(html`
${children}
`, root); }, }); }); @@ -489,7 +555,7 @@ describe('render', () => { }, render({ createElement }, root) { expect(createElement).toBe(mockCreateElement); - render(createElement('div', null, 'testSource'), root); + preactRender(createElement('div', null, 'testSource'), root); }, renderer: { createElement: mockCreateElement, @@ -527,7 +593,7 @@ describe('render', () => { }, render({ createElement, Fragment }, root) { expect(Fragment).toBe(CustomFragment); - render(createElement(Fragment, null, 'testSource'), root); + preactRender(createElement(Fragment, null, 'testSource'), root); }, renderer: { createElement: preactCreateElement, diff --git a/packages/autocomplete-js/src/__tests__/renderNoResults.test.ts b/packages/autocomplete-js/src/__tests__/renderNoResults.test.ts index 20d1d2214..3930a7537 100644 --- a/packages/autocomplete-js/src/__tests__/renderNoResults.test.ts +++ b/packages/autocomplete-js/src/__tests__/renderNoResults.test.ts @@ -2,7 +2,7 @@ import { fireEvent, waitFor } from '@testing-library/dom'; import { createElement as preactCreateElement, Fragment as PreactFragment, - render, + render as preactRender, } from 'preact'; import { autocomplete } from '../autocomplete'; @@ -37,7 +37,7 @@ describe('renderNoResults', () => { ]; }, renderNoResults({ createElement }, root) { - render(createElement('div', null, 'No results render'), root); + preactRender(createElement('div', null, 'No results render'), root); }, }); @@ -81,7 +81,7 @@ describe('renderNoResults', () => { ]; }, renderNoResults({ createElement }, root) { - render(createElement('div', null, 'No results render'), root); + preactRender(createElement('div', null, 'No results render'), root); }, }); @@ -124,7 +124,7 @@ describe('renderNoResults', () => { ]; }, renderNoResults({ createElement }, root) { - render(createElement('div', null, 'No results method'), root); + preactRender(createElement('div', null, 'No results method'), root); }, }); @@ -169,7 +169,7 @@ describe('renderNoResults', () => { expect(panelContainer.querySelector('.aa-Panel')).toBe( root ); - render(createElement('div', null, 'No results render'), root); + preactRender(createElement('div', null, 'No results render'), root); }, }); }); @@ -211,7 +211,7 @@ describe('renderNoResults', () => { completion: null, }); - render(createElement('div', null, 'No results render'), root); + preactRender(createElement('div', null, 'No results render'), root); }, }); @@ -283,7 +283,7 @@ describe('renderNoResults', () => { }), }); - render(createElement('div', null, 'testSource'), root); + preactRender(createElement('div', null, 'testSource'), root); }, }); @@ -355,7 +355,7 @@ describe('renderNoResults', () => { }), ]); - render(createElement('div', null, 'No results render'), root); + preactRender(createElement('div', null, 'No results render'), root); }, }); @@ -418,7 +418,7 @@ describe('renderNoResults', () => { }, }) ); - render(createElement('div', null, 'No results render'), root); + preactRender(createElement('div', null, 'No results render'), root); }, }); @@ -461,7 +461,7 @@ describe('renderNoResults', () => { }, renderNoResults({ createElement }, root) { expect(createElement).toBe(preactCreateElement); - render(createElement('div', null, 'No results render'), root); + preactRender(createElement('div', null, 'No results render'), root); }, }); }); @@ -494,7 +494,73 @@ describe('renderNoResults', () => { }, renderNoResults({ createElement, Fragment }, root) { expect(Fragment).toBe(PreactFragment); - render(createElement(Fragment, null, 'No results render'), root); + preactRender(createElement(Fragment, null, 'No results render'), root); + }, + }); + }); + + test('provides a default `render`', () => { + const container = document.createElement('div'); + const panelContainer = document.createElement('div'); + + document.body.appendChild(panelContainer); + autocomplete<{ label: string }>({ + container, + panelContainer, + initialState: { + isOpen: true, + }, + getSources() { + return [ + { + sourceId: 'testSource', + getItems() { + return [{ label: '1' }]; + }, + templates: { + item({ item }) { + return item.label; + }, + }, + }, + ]; + }, + renderNoResults({ render }, root) { + expect(render).toBe(preactRender); + render(null, root); + }, + }); + }); + + test('provides an `html` function', () => { + const container = document.createElement('div'); + const panelContainer = document.createElement('div'); + + document.body.appendChild(panelContainer); + autocomplete<{ label: string }>({ + container, + panelContainer, + initialState: { + isOpen: true, + }, + getSources() { + return [ + { + sourceId: 'testSource', + getItems() { + return [{ label: '1' }]; + }, + templates: { + item({ item }) { + return item.label; + }, + }, + }, + ]; + }, + renderNoResults({ children, render, html }, root) { + expect(html).toBeDefined(); + render(html`
${children}
`, root); }, }); }); @@ -528,7 +594,7 @@ describe('renderNoResults', () => { }, renderNoResults({ createElement }, root) { expect(createElement).toBe(mockCreateElement); - render(createElement('div', null, 'No results render'), root); + preactRender(createElement('div', null, 'No results render'), root); }, renderer: { createElement: mockCreateElement, @@ -566,7 +632,7 @@ describe('renderNoResults', () => { }, renderNoResults({ createElement, Fragment }, root) { expect(Fragment).toBe(CustomFragment); - render(createElement(Fragment, null, 'No results render'), root); + preactRender(createElement(Fragment, null, 'No results render'), root); }, renderer: { createElement: preactCreateElement, diff --git a/packages/autocomplete-js/src/__tests__/renderer.test.ts b/packages/autocomplete-js/src/__tests__/renderer.test.ts index 3adeb657e..105e8f22f 100644 --- a/packages/autocomplete-js/src/__tests__/renderer.test.ts +++ b/packages/autocomplete-js/src/__tests__/renderer.test.ts @@ -1,7 +1,8 @@ +import { warnCache } from '@algolia/autocomplete-shared'; import { createElement as preactCreateElement, Fragment as PreactFragment, - render, + render as preactRender, } from 'preact'; import { autocomplete } from '../autocomplete'; @@ -11,6 +12,10 @@ describe('renderer', () => { document.body.innerHTML = ''; }); + afterEach(() => { + warnCache.current = {}; + }); + test('defaults to the Preact implementation', () => { const container = document.createElement('div'); const panelContainer = document.createElement('div'); @@ -37,9 +42,17 @@ describe('renderer', () => { }, ]; }, - render({ createElement, Fragment }, root) { + render({ createElement, Fragment, render }, root) { + expect(createElement).toBe(preactCreateElement); + expect(Fragment).toBe(PreactFragment); + expect(render).toBe(preactRender); + + render(createElement(Fragment, null, 'testSource'), root); + }, + renderNoResults({ createElement, Fragment, render }, root) { expect(createElement).toBe(preactCreateElement); expect(Fragment).toBe(PreactFragment); + expect(render).toBe(preactRender); render(createElement(Fragment, null, 'testSource'), root); }, @@ -51,8 +64,10 @@ describe('renderer', () => { const panelContainer = document.createElement('div'); const CustomFragment = (props: any) => props.children; const mockCreateElement = jest.fn().mockImplementation(preactCreateElement); + const mockRender = jest.fn().mockImplementation(preactRender); document.body.appendChild(panelContainer); + autocomplete<{ label: string }>({ container, panelContainer, @@ -74,17 +89,366 @@ describe('renderer', () => { }, ]; }, - render({ createElement, Fragment }, root) { + render({ children, createElement, Fragment, render, html }, root) { expect(createElement).toBe(mockCreateElement); expect(Fragment).toBe(CustomFragment); + expect(render).toBe(mockRender); expect(mockCreateElement).toHaveBeenCalled(); - render(createElement(Fragment, null, 'testSource'), root); + mockCreateElement.mockClear(); + + render(html`
${children}
`, root); + + expect(mockCreateElement).toHaveBeenCalledTimes(1); + expect(mockCreateElement).toHaveBeenLastCalledWith( + 'div', + null, + expect.any(Object) + ); + }, + renderNoResults( + { children, createElement, Fragment, render, html }, + root + ) { + expect(createElement).toBe(mockCreateElement); + expect(Fragment).toBe(CustomFragment); + expect(render).toBe(mockRender); + expect(mockCreateElement).toHaveBeenCalled(); + + mockCreateElement.mockClear(); + + render(html`
${children}
`, root); + + expect(mockCreateElement).toHaveBeenCalledTimes(1); + expect(mockCreateElement).toHaveBeenLastCalledWith( + 'div', + null, + expect.any(Object) + ); }, renderer: { createElement: mockCreateElement, Fragment: CustomFragment, + render: mockRender, }, }); }); + + test('defaults `render` when not specified in the renderer', () => { + const container = document.createElement('div'); + const panelContainer = document.createElement('div'); + const CustomFragment = (props: any) => props.children; + const mockCreateElement = jest.fn().mockImplementation(preactCreateElement); + + document.body.appendChild(panelContainer); + + autocomplete<{ label: string }>({ + container, + panelContainer, + initialState: { + isOpen: true, + }, + getSources() { + return [ + { + sourceId: 'testSource', + getItems() { + return [{ label: '1' }]; + }, + templates: { + item({ item }) { + return item.label; + }, + }, + }, + ]; + }, + render({ createElement, Fragment, render }, root) { + expect(render).toBe(preactRender); + + preactRender(createElement(Fragment, null, 'testSource'), root); + }, + renderNoResults({ createElement, Fragment, render }, root) { + expect(render).toBe(preactRender); + + preactRender(createElement(Fragment, null, 'testSource'), root); + }, + renderer: { + createElement: mockCreateElement, + Fragment: CustomFragment, + }, + }); + }); + + test('warns about renderer mismatch when specifying an incomplete renderer', () => { + const container = document.createElement('div'); + const panelContainer = document.createElement('div'); + const mockCreateElement = jest.fn().mockImplementation(preactCreateElement); + const CustomFragment = (props: any) => props.children; + const mockRender = jest.fn().mockImplementation(preactRender); + + document.body.appendChild(panelContainer); + + expect(() => { + autocomplete<{ label: string }>({ + container, + panelContainer, + initialState: { + isOpen: true, + }, + getSources() { + return [ + { + sourceId: 'testSource', + getItems() { + return [{ label: '1' }]; + }, + templates: { + item({ item }) { + return item.label; + }, + }, + }, + ]; + }, + renderer: { + createElement: mockCreateElement, + Fragment: CustomFragment, + }, + }); + }).toWarnDev( + '[Autocomplete] You provided an incomplete `renderer` (missing: `renderer.render`). This can cause rendering issues.' + + '\nSee https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-js/autocomplete/#param-renderer' + ); + + expect(() => { + autocomplete<{ label: string }>({ + container, + panelContainer, + initialState: { + isOpen: true, + }, + getSources() { + return [ + { + sourceId: 'testSource', + getItems() { + return [{ label: '1' }]; + }, + templates: { + item({ item }) { + return item.label; + }, + }, + }, + ]; + }, + // Accidentally not passing required `renderer` properties is possible + // for Non-TypeScript users + // @ts-expect-error + renderer: { + Fragment: CustomFragment, + render: mockRender, + }, + }); + }).toWarnDev( + '[Autocomplete] You provided an incomplete `renderer` (missing: `renderer.createElement`). This can cause rendering issues.' + + '\nSee https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-js/autocomplete/#param-renderer' + ); + + expect(() => { + autocomplete<{ label: string }>({ + container, + panelContainer, + initialState: { + isOpen: true, + }, + getSources() { + return [ + { + sourceId: 'testSource', + getItems() { + return [{ label: '1' }]; + }, + templates: { + item({ item }) { + return item.label; + }, + }, + }, + ]; + }, + // Accidentally not passing required `renderer` properties is possible + // for Non-TypeScript users + // @ts-expect-error + renderer: { + createElement: mockCreateElement, + render: mockRender, + }, + }); + }).toWarnDev( + '[Autocomplete] You provided an incomplete `renderer` (missing: `renderer.Fragment`). This can cause rendering issues.' + + '\nSee https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-js/autocomplete/#param-renderer' + ); + + expect(() => { + autocomplete<{ label: string }>({ + container, + panelContainer, + initialState: { + isOpen: true, + }, + getSources() { + return [ + { + sourceId: 'testSource', + getItems() { + return [{ label: '1' }]; + }, + templates: { + item({ item }) { + return item.label; + }, + }, + }, + ]; + }, + // Accidentally not passing required `renderer` properties is possible + // for Non-TypeScript users + // @ts-expect-error + renderer: { + createElement: mockCreateElement, + }, + }); + }).toWarnDev( + '[Autocomplete] You provided an incomplete `renderer` (missing: `renderer.Fragment`, `renderer.render`). This can cause rendering issues.' + + '\nSee https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-js/autocomplete/#param-renderer' + ); + }); + + test('warns about new `renderer.render` option when specifying an incomplete renderer and a `render` option', () => { + const container = document.createElement('div'); + const panelContainer = document.createElement('div'); + const mockCreateElement = jest.fn().mockImplementation(preactCreateElement); + const CustomFragment = (props: any) => props.children; + + document.body.appendChild(panelContainer); + + function startAutocomplete() { + autocomplete<{ label: string }>({ + container, + panelContainer, + initialState: { + isOpen: true, + }, + getSources() { + return [ + { + sourceId: 'testSource', + getItems() { + return [{ label: '1' }]; + }, + templates: { + item({ item }) { + return item.label; + }, + }, + }, + ]; + }, + renderer: { + createElement: mockCreateElement, + Fragment: CustomFragment, + }, + render({ children }, root) { + preactRender(children, root); + }, + }); + } + + expect(startAutocomplete).toWarnDev( + '[Autocomplete] You provided the `render` option but did not provide a `renderer.render`. Since v1.6.0, you can provide a `render` function directly in `renderer`.' + + '\nTo get rid of this warning, do any of the following depending on your use case.' + + "\n- If you are using the `render` option only to override Autocomplete's default `render` function, pass the `render` function into `renderer` and remove the `render` option." + + '\n- If you are using the `render` option to customize the layout, pass your `render` function into `renderer` and use it from the provided parameters of the `render` option.' + + '\nSee https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-js/autocomplete/#param-render' + ); + + expect(startAutocomplete).not.toWarnDev( + '[Autocomplete] You provided an incomplete `renderer` (missing: `renderer.Fragment`, `renderer.render`). This can cause rendering issues.' + + '\nSee https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-js/autocomplete/#param-renderer' + ); + }); + + test('does not warn at all when not passing a custom renderer', () => { + const container = document.createElement('div'); + const panelContainer = document.createElement('div'); + + document.body.appendChild(panelContainer); + + expect(() => { + autocomplete<{ label: string }>({ + container, + panelContainer, + initialState: { + isOpen: true, + }, + getSources() { + return [ + { + sourceId: 'testSource', + getItems() { + return [{ label: '1' }]; + }, + templates: { + item({ item }) { + return item.label; + }, + }, + }, + ]; + }, + }); + }).not.toWarnDev(); + }); + + test('does not warn at all when passing a full custom renderer', () => { + const container = document.createElement('div'); + const panelContainer = document.createElement('div'); + const CustomFragment = (props: any) => props.children; + const mockCreateElement = jest.fn().mockImplementation(preactCreateElement); + const mockRender = jest.fn().mockImplementation(preactRender); + + document.body.appendChild(panelContainer); + + expect(() => { + autocomplete<{ label: string }>({ + container, + panelContainer, + initialState: { + isOpen: true, + }, + getSources() { + return [ + { + sourceId: 'testSource', + getItems() { + return [{ label: '1' }]; + }, + templates: { + item({ item }) { + return item.label; + }, + }, + }, + ]; + }, + renderer: { + createElement: mockCreateElement, + Fragment: CustomFragment, + render: mockRender, + }, + }); + }).not.toWarnDev(); + }); }); diff --git a/packages/autocomplete-js/src/__tests__/templates.test.tsx b/packages/autocomplete-js/src/__tests__/templates.test.tsx new file mode 100644 index 000000000..009fb7e43 --- /dev/null +++ b/packages/autocomplete-js/src/__tests__/templates.test.tsx @@ -0,0 +1,582 @@ +/** @jsx h */ +import { autocomplete } from '@algolia/autocomplete-js'; +import { Hit } from '@algolia/client-search'; +import { fireEvent, waitFor, within } from '@testing-library/dom'; +import { h } from 'preact'; + +import { HTMLTemplate } from '../types'; + +import products from './fixtures/products.json'; + +type ProductRecord = { + brand: string; + categories: string[]; + description: string; + free_shipping: boolean; + hierarchicalCategories: { + lvl0: string; + lvl1?: string; + lvl2?: string; + lvl3?: string; + lvl4?: string; + lvl5?: string; + lvl6?: string; + }; + image: string; + name: string; + popularity: number; + price: number; + prince_range: string; + rating: number; + type: string; +}; +type ProductHit = Hit; + +const productHits = products.results[0].hits; + +describe('templates', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + test('renders JSX templates', async () => { + const container = document.createElement('div'); + const panelContainer = document.createElement('div'); + + document.body.appendChild(panelContainer); + + autocomplete<{ label: string }>({ + container, + panelContainer, + id: 'autocomplete-0', + getSources() { + return [ + { + sourceId: 'testSource', + getItems({ query }) { + return [{ label: '1' }].filter(({ label }) => + label.includes(query) + ); + }, + templates: { + header() { + return
Header
; + }, + item({ item }) { + return
{item.label}
; + }, + footer() { + return
Footer
; + }, + noResults() { + return
No results
; + }, + }, + }, + ]; + }, + }); + + const input = container.querySelector('.aa-Input'); + + fireEvent.input(input, { target: { value: '1' } }); + + await waitFor(() => { + expect(within(panelContainer).getByRole('banner')).toMatchInlineSnapshot(` +
+ Header +
+ `); + expect(within(panelContainer).getByRole('listbox')) + .toMatchInlineSnapshot(` +
    +
  • +
    + 1 +
    +
  • +
+ `); + expect(within(panelContainer).getByRole('contentinfo')) + .toMatchInlineSnapshot(` +
+ Footer +
+ `); + }); + + fireEvent.input(input, { target: { value: 'nothing' } }); + + await waitFor(() => { + expect(within(panelContainer).getByText('No results').parentNode) + .toMatchInlineSnapshot(` +
+
+ No results +
+
+ `); + }); + }); + + test('renders templates using `createElement`', async () => { + const container = document.createElement('div'); + const panelContainer = document.createElement('div'); + + document.body.appendChild(panelContainer); + + autocomplete<{ label: string }>({ + container, + panelContainer, + id: 'autocomplete-0', + getSources() { + return [ + { + sourceId: 'testSource', + getItems({ query }) { + return [{ label: '1' }].filter(({ label }) => + label.includes(query) + ); + }, + templates: { + header({ createElement }) { + return createElement( + 'header', + { className: 'MyCustomHeaderClass' }, + 'Header' + ); + }, + item({ item, createElement }) { + return createElement( + 'div', + { className: 'MyCustomItemClass' }, + item.label + ); + }, + footer({ createElement }) { + return createElement( + 'footer', + { className: 'MyCustomFooterClass' }, + 'Footer' + ); + }, + noResults({ createElement }) { + return createElement( + 'div', + { className: 'MyCustomNoResultsClass' }, + 'No results' + ); + }, + }, + }, + ]; + }, + }); + + const input = container.querySelector('.aa-Input'); + + fireEvent.input(input, { target: { value: '1' } }); + + await waitFor(() => { + expect(within(panelContainer).getByRole('banner')).toMatchInlineSnapshot(` +
+ Header +
+ `); + expect(within(panelContainer).getByRole('listbox')) + .toMatchInlineSnapshot(` +
    +
  • +
    + 1 +
    +
  • +
+ `); + expect(within(panelContainer).getByRole('contentinfo')) + .toMatchInlineSnapshot(` +
+ Footer +
+ `); + }); + + fireEvent.input(input, { target: { value: 'nothing' } }); + + await waitFor(() => { + expect(within(panelContainer).getByText('No results').parentNode) + .toMatchInlineSnapshot(` +
+
+ No results +
+
+ `); + }); + }); + + test('renders templates using `html`', async () => { + const container = document.createElement('div'); + const panelContainer = document.createElement('div'); + + document.body.appendChild(panelContainer); + + autocomplete({ + container, + panelContainer, + id: 'autocomplete-0', + getSources() { + return [ + { + sourceId: 'testSource', + getItems({ query }) { + return [productHits[0]].filter(({ name }) => + name.toLowerCase().includes(query) + ); + }, + templates: { + header({ html }) { + return html`
+ Header +
`; + }, + item({ item, components, html }) { + return html`
+

+ ${components.Highlight({ + hit: item, + attribute: 'name', + })} +

+

$${item.price}

+
`; + }, + footer({ html }) { + return html`
+ Footer +
`; + }, + noResults({ html }) { + return html`
+ No results +
`; + }, + }, + }, + ]; + }, + render({ children, render, html }, root) { + render( + html`
+ ${children} +
`, + root + ); + }, + }); + + const input = container.querySelector('.aa-Input'); + + fireEvent.input(input, { target: { value: 'apple' } }); + + await waitFor(() => { + expect(within(panelContainer).getByTestId('results')) + .toMatchInlineSnapshot(` +
+
+
+
+
+ Header +
+
+
    +
  • +
    +

    + Apple - + + iPhone + + SE 16GB - Space Gray (Verizon) +

    +

    + $ + 449.99 +

    +
    +
  • +
+
+
+ Footer +
+
+
+
+
+
+ `); + }); + + fireEvent.input(input, { target: { value: 'nothing' } }); + + await waitFor(() => { + expect(within(panelContainer).getByText('No results').parentNode) + .toMatchInlineSnapshot(` +
+
+ No results +
+
+ `); + }); + }); + + test('renders templates using documented `html` shim (for IE 11)', async () => { + const container = document.createElement('div'); + const panelContainer = document.createElement('div'); + + document.body.appendChild(panelContainer); + + autocomplete({ + container, + panelContainer, + id: 'autocomplete-0', + getSources() { + return [ + { + sourceId: 'testSource', + getItems({ query }) { + return [productHits[0]].filter(({ name }) => + name.toLowerCase().includes(query) + ); + }, + templates: { + header({ html }) { + return htmlShim( + '
Header
', + html + ); + }, + item({ item, components, html }) { + return htmlShim( + [ + '

', + components.Highlight({ + hit: item, + attribute: 'name', + }), + '

', + item.price, + '

', + ], + html + ); + }, + footer({ html }) { + return htmlShim( + '
Footer
', + html + ); + }, + noResults({ html }) { + return htmlShim( + '
No results
', + html + ); + }, + }, + }, + ]; + }, + render({ children, render, html }, root) { + render( + htmlShim( + [ + '
', + children, + '
', + ], + html + ), + root + ); + }, + }); + + const input = container.querySelector('.aa-Input'); + + fireEvent.input(input, { target: { value: 'apple' } }); + + await waitFor(() => { + expect(within(panelContainer).getByTestId('results')) + .toMatchInlineSnapshot(` +
+
+
+
+
+ Header +
+
+
    +
  • + div +
  • +
+
+
+ Footer +
+
+
+
+
+
+ `); + }); + + fireEvent.input(input, { target: { value: 'nothing' } }); + + await waitFor(() => { + expect(within(panelContainer).getByText('No results').parentNode) + .toMatchInlineSnapshot(` +
+
+ No results +
+
+ `); + }); + }); +}); + +function htmlShim(template: Array | string, html: HTMLTemplate) { + if (typeof template === 'string') { + return html(([template] as unknown) as TemplateStringsArray); + } + + const [strings, variables] = template.reduce( + (acc, part, index) => { + const isEven = index % 2 === 0; + + acc[Math.abs(Number(!isEven))].push(part); + + return acc; + }, + [[], []] + ); + + // Before TypeScript 2, `TemplateStringsArray` was assignable to `string[]`. + // This is no longer the case, but `htm` does accept `string[]`. + // Since this solution is for IE11 users who don't have a build step, it isn't + // necessary to complexify the function to make it type-safe. + return html((strings as unknown) as TemplateStringsArray, ...variables); +} diff --git a/packages/autocomplete-js/src/autocomplete.ts b/packages/autocomplete-js/src/autocomplete.ts index 28bbce84e..fdd63f6f5 100644 --- a/packages/autocomplete-js/src/autocomplete.ts +++ b/packages/autocomplete-js/src/autocomplete.ts @@ -8,6 +8,7 @@ import { debounce, getItemsCount, } from '@algolia/autocomplete-shared'; +import htm from 'htm'; import { createAutocompleteDom } from './createAutocompleteDom'; import { createEffectWrapper } from './createEffectWrapper'; @@ -21,6 +22,7 @@ import { AutocompletePropGetters, AutocompleteSource, AutocompleteState, + VNode, } from './types'; import { userAgents } from './userAgents'; import { mergeDeep, setProperties } from './utils'; @@ -112,6 +114,10 @@ export function autocomplete( refresh: autocomplete.value.refresh, }; + const html = reactive(() => + htm.bind(props.value.renderer.renderer.createElement) + ); + const dom = reactive(() => createAutocompleteDom({ autocomplete: autocomplete.value, @@ -149,14 +155,14 @@ export function autocomplete( classNames: props.value.renderer.classNames, components: props.value.renderer.components, container: props.value.renderer.container, - createElement: props.value.renderer.renderer.createElement, + html: html.value, dom: dom.value, - Fragment: props.value.renderer.renderer.Fragment, panelContainer: isDetached.value ? dom.value.detachedContainer : props.value.renderer.panelContainer, propGetters, state: lastStateRef.current, + renderer: props.value.renderer.renderer, }; const render = diff --git a/packages/autocomplete-js/src/getDefaultOptions.ts b/packages/autocomplete-js/src/getDefaultOptions.ts index f35d8e8cc..dd5b7c3a1 100644 --- a/packages/autocomplete-js/src/getDefaultOptions.ts +++ b/packages/autocomplete-js/src/getDefaultOptions.ts @@ -2,6 +2,7 @@ import { AutocompleteEnvironment, BaseItem } from '@algolia/autocomplete-core'; import { generateAutocompleteId, invariant, + warn, } from '@algolia/autocomplete-shared'; import { createElement as preactCreateElement, @@ -60,6 +61,7 @@ const defaultRender: AutocompleteRender = ({ children }, root) => { const defaultRenderer: AutocompleteRenderer = { createElement: preactCreateElement, Fragment: PreactFragment, + render, }; export function getDefaultOptions( @@ -99,7 +101,30 @@ export function getDefaultOptions( 'The `container` option does not support `input` elements. You need to change the container to a `div`.' ); - const defaultedRenderer = renderer ?? defaultRenderer; + warn( + Boolean(!render || renderer?.render), + `You provided the \`render\` option but did not provide a \`renderer.render\`. Since v1.6.0, you can provide a \`render\` function directly in \`renderer\`.` + + `\nTo get rid of this warning, do any of the following depending on your use case.` + + "\n- If you are using the `render` option only to override Autocomplete's default `render` function, pass the `render` function into `renderer` and remove the `render` option." + + '\n- If you are using the `render` option to customize the layout, pass your `render` function into `renderer` and use it from the provided parameters of the `render` option.' + + '\nSee https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-js/autocomplete/#param-render' + ); + + warn( + !renderer || + render || + (renderer.Fragment && renderer.createElement && renderer.render), + `You provided an incomplete \`renderer\` (missing: ${[ + !renderer?.createElement && '`renderer.createElement`', + !renderer?.Fragment && '`renderer.Fragment`', + !renderer?.render && '`renderer.render`', + ] + .filter(Boolean) + .join(', ')}). This can cause rendering issues.` + + '\nSee https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-js/autocomplete/#param-renderer' + ); + + const defaultedRenderer = { ...defaultRenderer, ...renderer }; const defaultComponents: AutocompleteComponents = { Highlight: createHighlightComponent(defaultedRenderer), ReverseHighlight: createReverseHighlightComponent(defaultedRenderer), diff --git a/packages/autocomplete-js/src/render.tsx b/packages/autocomplete-js/src/render.tsx index 3c1767746..dd56a5f07 100644 --- a/packages/autocomplete-js/src/render.tsx +++ b/packages/autocomplete-js/src/render.tsx @@ -1,9 +1,9 @@ -/** @jsx createElement */ +/** @jsx renderer.createElement */ import { AutocompleteApi as AutocompleteCoreApi, AutocompleteScopeApi, + BaseItem, } from '@algolia/autocomplete-core'; -import { BaseItem } from '@algolia/autocomplete-core/src'; import { AutocompleteClassNames, @@ -11,9 +11,9 @@ import { AutocompleteDom, AutocompletePropGetters, AutocompleteRender, + AutocompleteRenderer, AutocompleteState, - Pragma, - PragmaFrag, + HTMLTemplate, } from './types'; import { setProperties, setPropertiesWithoutEvents } from './utils'; @@ -22,12 +22,12 @@ type RenderProps = { autocompleteScopeApi: AutocompleteScopeApi; classNames: AutocompleteClassNames; components: AutocompleteComponents; - createElement: Pragma; + html: HTMLTemplate; dom: AutocompleteDom; - Fragment: PragmaFrag; panelContainer: HTMLElement; propGetters: AutocompletePropGetters; state: AutocompleteState; + renderer: AutocompleteRenderer; }; export function renderSearchBox({ @@ -65,13 +65,13 @@ export function renderPanel( autocomplete, autocompleteScopeApi, classNames, - createElement, + html, dom, - Fragment, panelContainer, propGetters, state, components, + renderer, }: RenderProps ): void { if (!state.isOpen) { @@ -104,11 +104,12 @@ export function renderPanel(
{source.templates.header({ components, - createElement, - Fragment, + createElement: renderer.createElement, + Fragment: renderer.Fragment, items, source, state, + html, })}
)} @@ -117,10 +118,11 @@ export function renderPanel(
{source.templates.noResults({ components, - createElement, - Fragment, + createElement: renderer.createElement, + Fragment: renderer.Fragment, source, state, + html, })}
) : ( @@ -150,10 +152,11 @@ export function renderPanel( > {source.templates.item({ components, - createElement, - Fragment, + createElement: renderer.createElement, + Fragment: renderer.Fragment, item, state, + html, })} ); @@ -165,11 +168,12 @@ export function renderPanel(
{source.templates.footer({ components, - createElement, - Fragment, + createElement: renderer.createElement, + Fragment: renderer.Fragment, items, source, state, + html, })}
)} @@ -177,10 +181,10 @@ export function renderPanel( )); const children = ( - +
{sections}
- + ); const elements = sections.reduce((acc, current) => { acc[current.props['data-autocomplete-source-id']] = current; @@ -193,9 +197,9 @@ export function renderPanel( state, sections, elements, - createElement, - Fragment, + ...renderer, components, + html, ...autocompleteScopeApi, }, dom.panel diff --git a/packages/autocomplete-js/src/types/AutocompleteRender.ts b/packages/autocomplete-js/src/types/AutocompleteRender.ts index 46f449ef4..63116e362 100644 --- a/packages/autocomplete-js/src/types/AutocompleteRender.ts +++ b/packages/autocomplete-js/src/types/AutocompleteRender.ts @@ -1,7 +1,13 @@ import { AutocompleteScopeApi, BaseItem } from '@algolia/autocomplete-core'; import { AutocompleteComponents } from './AutocompleteComponents'; -import { Pragma, PragmaFrag, VNode } from './AutocompleteRenderer'; +import { + HTMLTemplate, + Pragma, + PragmaFrag, + Render, + VNode, +} from './AutocompleteRenderer'; import { AutocompleteState } from './AutocompleteState'; export type AutocompleteRender = ( @@ -13,6 +19,8 @@ export type AutocompleteRender = ( components: AutocompleteComponents; createElement: Pragma; Fragment: PragmaFrag; + html: HTMLTemplate; + render?: Render; }, root: HTMLElement ) => void; diff --git a/packages/autocomplete-js/src/types/AutocompleteRenderer.ts b/packages/autocomplete-js/src/types/AutocompleteRenderer.ts index 5504f463b..7a1fec145 100644 --- a/packages/autocomplete-js/src/types/AutocompleteRenderer.ts +++ b/packages/autocomplete-js/src/types/AutocompleteRenderer.ts @@ -20,6 +20,12 @@ export type VNode = { props: TProps & { children: ComponentChildren; key?: any }; }; +export type Render = ( + vnode: ComponentChild, + parent: Element | Document | ShadowRoot | DocumentFragment, + replaceNode?: Element | Text | undefined +) => void; + export type AutocompleteRenderer = { /** * The function to create virtual nodes. @@ -33,4 +39,13 @@ export type AutocompleteRenderer = { * @default preact.Fragment */ Fragment: PragmaFrag; + /** + * The function to render children to an element. + */ + render?: Render; }; + +export type HTMLTemplate = ( + strings: TemplateStringsArray, + ...values: any[] +) => VNode | VNode[]; diff --git a/packages/autocomplete-js/src/types/AutocompleteSource.ts b/packages/autocomplete-js/src/types/AutocompleteSource.ts index 1ebe7e702..8df903593 100644 --- a/packages/autocomplete-js/src/types/AutocompleteSource.ts +++ b/packages/autocomplete-js/src/types/AutocompleteSource.ts @@ -5,15 +5,20 @@ import { } from '@algolia/autocomplete-core'; import { AutocompleteComponents } from './AutocompleteComponents'; -import { AutocompleteRenderer, VNode } from './AutocompleteRenderer'; +import { + AutocompleteRenderer, + HTMLTemplate, + VNode, +} from './AutocompleteRenderer'; import { AutocompleteState } from './AutocompleteState'; type Template = ( params: TParams & AutocompleteRenderer & { components: AutocompleteComponents; + html: HTMLTemplate; } -) => VNode | string; +) => VNode | VNode[] | string; /** * Templates to display in the autocomplete panel. diff --git a/yarn.lock b/yarn.lock index 1178b9b36..11d19196f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11644,6 +11644,11 @@ hsla-regex@^1.0.0: resolved "https://registry.yarnpkg.com/hsla-regex/-/hsla-regex-1.0.0.tgz#c1ce7a3168c8c6614033a4b5f7877f3b225f9c38" integrity sha1-wc56MWjIxmFAM6S194d/OyJfnDg= +htm@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/htm/-/htm-3.1.0.tgz#0c305493b60da9f6ed097a2aaf4c994bd85ea022" + integrity sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q== + html-encoding-sniffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz#e70d84b94da53aa375e11fe3a351be6642ca46f8"