Skip to content
Merged
2 changes: 1 addition & 1 deletion bundlesize.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
},
{
"path": "packages/autocomplete-js/dist/umd/index.production.js",
"maxSize": "17.5 kB"
"maxSize": "17.75 kB"
},
{
"path": "packages/autocomplete-preset-algolia/dist/umd/index.production.js",
Expand Down
75 changes: 73 additions & 2 deletions packages/autocomplete-js/src/__tests__/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,8 +398,10 @@ describe('api', () => {
return [{ label: query }];
},
templates: {
item({ item, html }) {
return html`<div>${item.label}</div>`;
item({ item, components, html }) {
return html`<div>
${components.Highlight({ hit: item, attribute: 'label' })}
</div>`;
},
},
},
Expand Down Expand Up @@ -445,6 +447,75 @@ describe('api', () => {
expect(mockCreateElement2).toHaveBeenCalled();
});
});

test('preserves all user `components` when not updated', 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 CustomHighlight = jest.fn((props: { hit: { label: string } }) =>
mockCreateElement1(CustomFragment, null, props.hit.label)
);
const MyComponent = (props: any) => props.children;

const { update } = autocomplete<{ label: string }>({
container,
panelContainer,
getSources() {
return [
{
sourceId: 'testSource',
getItems({ query }) {
return [{ label: query }];
},
templates: {
item({ item, components, html }) {
return html`<div>
${components.Highlight({ hit: item, attribute: 'label' })}
${components.MyComponent({ children: item.label })}
</div>`;
},
},
},
];
},
components: { Highlight: CustomHighlight, MyComponent },
renderer: {
Fragment: CustomFragment,
render: mockRender,
createElement: mockCreateElement1,
},
});

update({
renderer: {
Fragment: CustomFragment,
render: mockRender,
createElement: mockCreateElement2,
},
});

mockCreateElement1.mockClear();

const input = container.querySelector<HTMLInputElement>('.aa-Input');

fireEvent.input(input, { target: { value: 'iphone' } });

await waitFor(() => {
expect(
panelContainer.querySelector<HTMLElement>('.aa-Panel')
).toHaveTextContent('iphone');
// The custom `Highlight` component wasn't updated, so the previous
// `createElement` implementation is still being called.
expect(mockCreateElement1).toHaveBeenCalledTimes(1);
expect(mockCreateElement2).toHaveBeenCalled();
});
});
});

describe('destroy', () => {
Expand Down
179 changes: 174 additions & 5 deletions packages/autocomplete-js/src/__tests__/components.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
/** @jsx h */
import { Hit } from '@algolia/client-search';
import { fireEvent, waitFor } from '@testing-library/dom';
import { h } from 'preact';
import {
Fragment,
createElement as preactCreateElement,
h,
render,
} from 'preact';

import { createSource } from '../../../../test/utils';
import { autocomplete } from '../autocomplete';
Expand Down Expand Up @@ -121,7 +126,48 @@ describe('components', () => {
});
});

test.todo('provides Highlight component with custom createElement');
test('provides Highlight component with custom createElement', async () => {
const container = document.createElement('div');
const panelContainer = document.createElement('div');

const mockCreateElement = jest.fn(preactCreateElement);

document.body.appendChild(panelContainer);
autocomplete<ProductHit>({
container,
panelContainer,
getSources() {
return [
{
...createSource({
getItems() {
return productHits;
},
}),
templates: {
item({ item, components }) {
return (
<components.Highlight
hit={item}
attribute="name"
tagName="em"
/>
);
},
},
},
];
},
renderer: { createElement: mockCreateElement, Fragment, render },
});

const input = container.querySelector<HTMLInputElement>('.aa-Input');
fireEvent.input(input, { target: { value: 'a' } });

await waitFor(() => {
expect(mockCreateElement).toHaveBeenCalled();
});
});

test('provides Snippet component', async () => {
const container = document.createElement('div');
Expand Down Expand Up @@ -205,7 +251,48 @@ describe('components', () => {
});
});

test.todo('provides Snippet component with custom createElement');
test('provides Snippet component with custom createElement', async () => {
const container = document.createElement('div');
const panelContainer = document.createElement('div');

const mockCreateElement = jest.fn(preactCreateElement);

document.body.appendChild(panelContainer);
autocomplete<ProductHit>({
container,
panelContainer,
getSources() {
return [
{
...createSource({
getItems() {
return productHits;
},
}),
templates: {
item({ item, components }) {
return (
<components.Snippet
hit={item}
attribute="name"
tagName="em"
/>
);
},
},
},
];
},
renderer: { createElement: mockCreateElement, Fragment, render },
});

const input = container.querySelector<HTMLInputElement>('.aa-Input');
fireEvent.input(input, { target: { value: 'a' } });

await waitFor(() => {
expect(mockCreateElement).toHaveBeenCalled();
});
});

test('provides ReverseHighlight component', async () => {
const container = document.createElement('div');
Expand Down Expand Up @@ -291,7 +378,48 @@ describe('components', () => {
});
});

test.todo('provides ReverseHighlight component with custom createElement');
test('provides ReverseHighlight component with custom createElement', async () => {
const container = document.createElement('div');
const panelContainer = document.createElement('div');

const mockCreateElement = jest.fn(preactCreateElement);

document.body.appendChild(panelContainer);
autocomplete<ProductHit>({
container,
panelContainer,
getSources() {
return [
{
...createSource({
getItems() {
return productHits;
},
}),
templates: {
item({ item, components }) {
return (
<components.ReverseHighlight
hit={item}
attribute="name"
tagName="em"
/>
);
},
},
},
];
},
renderer: { createElement: mockCreateElement, Fragment, render },
});

const input = container.querySelector<HTMLInputElement>('.aa-Input');
fireEvent.input(input, { target: { value: 'a' } });

await waitFor(() => {
expect(mockCreateElement).toHaveBeenCalled();
});
});

test('provides ReverseSnippet component', async () => {
const container = document.createElement('div');
Expand Down Expand Up @@ -377,7 +505,48 @@ describe('components', () => {
});
});

test.todo('provides ReverseSnippet component with custom createElement');
test('provides ReverseSnippet component with custom createElement', async () => {
const container = document.createElement('div');
const panelContainer = document.createElement('div');

const mockCreateElement = jest.fn(preactCreateElement);

document.body.appendChild(panelContainer);
autocomplete<ProductHit>({
container,
panelContainer,
getSources() {
return [
{
...createSource({
getItems() {
return productHits;
},
}),
templates: {
item({ item, components }) {
return (
<components.ReverseSnippet
hit={item}
attribute="name"
tagName="em"
/>
);
},
},
},
];
},
renderer: { createElement: mockCreateElement, Fragment, render },
});

const input = container.querySelector<HTMLInputElement>('.aa-Input');
fireEvent.input(input, { target: { value: 'a' } });

await waitFor(() => {
expect(mockCreateElement).toHaveBeenCalled();
});
});

test('allows registering custom components', async () => {
const container = document.createElement('div');
Expand Down
19 changes: 16 additions & 3 deletions packages/autocomplete-js/src/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
VNode,
} from './types';
import { userAgents } from './userAgents';
import { mergeDeep, setProperties } from './utils';
import { mergeDeep, pickBy, setProperties } from './utils';

export function autocomplete<TItem extends BaseItem>(
options: AutocompleteOptions<TItem>
Expand Down Expand Up @@ -341,10 +341,23 @@ export function autocomplete<TItem extends BaseItem>(
function update(updatedOptions: Partial<AutocompleteOptions<TItem>> = {}) {
cleanupEffects();

const { components, ...rendererProps } = props.value.renderer;

optionsRef.current = mergeDeep(
props.value.renderer,
rendererProps,
props.value.core,
{ initialState: lastStateRef.current },
{
// We need to filter out default components so they can be replaced with
// a new `renderer`, without getting rid of user components.
// @MAJOR Deal with registering components with the same name as the
// default ones. If we disallow overriding default components, we'd just
// need to pass all `components` here.
components: pickBy(
components,
({ value }) => !value.hasOwnProperty('__autocomplete_componentName')
),
initialState: lastStateRef.current,
},
updatedOptions
);

Expand Down
8 changes: 6 additions & 2 deletions packages/autocomplete-js/src/components/Highlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export function createHighlightComponent({
createElement,
Fragment,
}: AutocompleteRenderer) {
return function Highlight<THit>({
function Highlight<THit>({
hit,
attribute,
tagName = 'mark',
Expand All @@ -20,5 +20,9 @@ export function createHighlightComponent({
: x.value
)
);
};
}

Highlight.__autocomplete_componentName = 'Highlight';

return Highlight;
}
8 changes: 6 additions & 2 deletions packages/autocomplete-js/src/components/ReverseHighlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export function createReverseHighlightComponent({
createElement,
Fragment,
}: AutocompleteRenderer) {
return function ReverseHighlight<THit>({
function ReverseHighlight<THit>({
hit,
attribute,
tagName = 'mark',
Expand All @@ -23,5 +23,9 @@ export function createReverseHighlightComponent({
: x.value
)
);
};
}

ReverseHighlight.__autocomplete_componentName = 'ReverseHighlight';

return ReverseHighlight;
}
Loading