diff --git a/.eslintrc.js b/.eslintrc.js index e180bc7f8..77052e044 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -26,7 +26,6 @@ module.exports = { 'eslint-comments/disable-enable-pair': ['error', { allowWholeFile: true }], 'import/extensions': 0, '@typescript-eslint/camelcase': ['error', { allow: ['__autocomplete_id'] }], - '@typescript-eslint/no-use-before-define': 0, // Useful to call functions like `nodeItem?.scrollIntoView()`. 'no-unused-expressions': 0, complexity: 0, diff --git a/packages/autocomplete-js/src/__tests__/autocomplete.test.ts b/packages/autocomplete-js/src/__tests__/autocomplete.test.ts index 0dd955b4b..1845cde43 100644 --- a/packages/autocomplete-js/src/__tests__/autocomplete.test.ts +++ b/packages/autocomplete-js/src/__tests__/autocomplete.test.ts @@ -102,10 +102,6 @@ describe('autocomplete-js', () => { - `); diff --git a/packages/autocomplete-js/src/autocomplete.ts b/packages/autocomplete-js/src/autocomplete.ts index 81ab5afd2..013063cb8 100644 --- a/packages/autocomplete-js/src/autocomplete.ts +++ b/packages/autocomplete-js/src/autocomplete.ts @@ -1,4 +1,5 @@ import { createAutocomplete } from '@algolia/autocomplete-core'; +import { createRef } from '@algolia/autocomplete-shared'; import { createAutocompleteDom } from './createAutocompleteDom'; import { createEffectWrapper } from './createEffectWrapper'; @@ -19,20 +20,25 @@ function defaultRenderer({ root, sections }) { export function autocomplete({ container, + panelContainer = document.body, render: renderer = defaultRenderer, panelPlacement = 'input-wrapper-width', classNames = {}, ...props }: AutocompleteOptions): AutocompleteApi { const { runEffect, cleanupEffects } = createEffectWrapper(); + const onStateChangeRef = createRef< + | ((params: { + state: AutocompleteState; + prevState: AutocompleteState; + }) => void) + | undefined + >(undefined); const autocomplete = createAutocomplete({ ...props, onStateChange(options) { - onStateChange(options.state); - - if (props.onStateChange) { - props.onStateChange(options); - } + onStateChangeRef.current?.(options as any); + props.onStateChange?.(options); }, }); @@ -49,28 +55,6 @@ export function autocomplete({ classNames, }); - // This batches state changes to limit DOM mutations. - // Every time we call a setter in `autocomplete-core` (e.g., in `onInput`), - // the core `onStateChange` function is called. - // We don't need to be notified of all these state changes to render. - // As an example: - // - without debouncing: "iphone case" query → 85 renders - // - with debouncing: "iphone case" query → 12 renders - const onStateChange = debounce((state: AutocompleteState) => { - render(renderer, { - state, - ...autocomplete, - classNames, - root, - form, - input, - inputWrapper, - label, - panel, - resetButton, - }); - }, 0); - function setPanelPosition() { setProperties(panel, { style: getPanelPositionStyle({ @@ -82,10 +66,6 @@ export function autocomplete({ }); } - requestAnimationFrame(() => { - setPanelPosition(); - }); - runEffect(() => { const environmentProps = autocomplete.getEnvironmentProps({ searchBoxElement: form, @@ -108,6 +88,38 @@ export function autocomplete({ }; }); + runEffect(() => { + const panelRoot = getHTMLElement(panelContainer); + const unmountRef = createRef<(() => void) | undefined>(undefined); + // This batches state changes to limit DOM mutations. + // Every time we call a setter in `autocomplete-core` (e.g., in `onInput`), + // the core `onStateChange` function is called. + // We don't need to be notified of all these state changes to render. + // As an example: + // - without debouncing: "iphone case" query → 85 renders + // - with debouncing: "iphone case" query → 12 renders + onStateChangeRef.current = debounce(({ state }) => { + unmountRef.current = render(renderer, { + state, + ...autocomplete, + classNames, + panelRoot, + root, + form, + input, + inputWrapper, + label, + panel, + resetButton, + }); + }, 0); + + return () => { + unmountRef.current?.(); + onStateChangeRef.current = undefined; + }; + }); + runEffect(() => { const containerElement = getHTMLElement(container); containerElement.appendChild(root); @@ -118,7 +130,7 @@ export function autocomplete({ }); runEffect(() => { - const onResize = debounce(() => { + const onResize = debounce(() => { setPanelPosition(); }, 100); @@ -129,6 +141,10 @@ export function autocomplete({ }; }); + requestAnimationFrame(() => { + setPanelPosition(); + }); + return { setSelectedItemId: autocomplete.setSelectedItemId, setQuery: autocomplete.setQuery, diff --git a/packages/autocomplete-js/src/render.ts b/packages/autocomplete-js/src/render.ts index 327bb941e..3a8a64af8 100644 --- a/packages/autocomplete-js/src/render.ts +++ b/packages/autocomplete-js/src/render.ts @@ -20,6 +20,7 @@ import { setPropertiesWithoutEvents } from './utils'; type RenderProps = { state: AutocompleteState; classNames: AutocompleteClassNames; + panelRoot: HTMLElement; } & AutocompleteCoreApi & AutocompleteDom; @@ -32,26 +33,27 @@ export function render( getListProps, getItemProps, classNames, + panelRoot, root, input, panel, }: RenderProps -): void { +): () => void { setPropertiesWithoutEvents(root, getRootProps()); setPropertiesWithoutEvents(input, getInputProps({ inputElement: input })); panel.innerHTML = ''; if (!state.isOpen) { - if (root.contains(panel)) { - root.removeChild(panel); + if (panelRoot.contains(panel)) { + panelRoot.removeChild(panel); } - return; + return () => {}; } - if (!root.contains(panel)) { - root.appendChild(panel); + if (!panelRoot.contains(panel)) { + panelRoot.appendChild(panel); } if (state.status === 'stalled') { @@ -116,4 +118,8 @@ export function render( panel.appendChild(panelLayoutElement); renderer({ root: panelLayoutElement, sections, state }); + + return () => { + panelRoot.removeChild(panel); + }; } diff --git a/packages/autocomplete-js/src/types/index.ts b/packages/autocomplete-js/src/types/index.ts index bcfb862fd..39ffae456 100644 --- a/packages/autocomplete-js/src/types/index.ts +++ b/packages/autocomplete-js/src/types/index.ts @@ -100,11 +100,19 @@ export type AutocompleteRenderer = (params: { export interface AutocompleteOptions extends AutocompleteCoreOptions { /** - * The container for the autocomplete search box. + * The container for the Autocomplete search box. * * You can either pass a [CSS selector](https://developer.mozilla.org/docs/Web/CSS/CSS_Selectors) or an [Element](https://developer.mozilla.org/docs/Web/API/HTMLElement). The first element matching the provided selector will be used as container. */ container: string | HTMLElement; + /** + * The container for the Autocomplete panel. + * + * You can either pass a [CSS selector](https://developer.mozilla.org/docs/Web/CSS/CSS_Selectors) or an [Element](https://developer.mozilla.org/docs/Web/API/HTMLElement). The first element matching the provided selector will be used as container. + * + * @default document.body + */ + panelContainer: string | HTMLElement; getSources?: ( params: GetSourcesParams ) => MaybePromise>>; diff --git a/packages/autocomplete-js/src/utils/debounce.ts b/packages/autocomplete-js/src/utils/debounce.ts index 31c638ab6..e505ead9c 100644 --- a/packages/autocomplete-js/src/utils/debounce.ts +++ b/packages/autocomplete-js/src/utils/debounce.ts @@ -1,7 +1,10 @@ -export function debounce(fn: Function, time: number) { +export function debounce( + fn: (...params: TParams[]) => void, + time: number +) { let timerId: ReturnType | undefined = undefined; - return function (...args: unknown[]) { + return function (...args: TParams[]) { if (timerId) { clearTimeout(timerId); } diff --git a/packages/autocomplete-shared/src/createRef.ts b/packages/autocomplete-shared/src/createRef.ts new file mode 100644 index 000000000..e331f6108 --- /dev/null +++ b/packages/autocomplete-shared/src/createRef.ts @@ -0,0 +1,5 @@ +export function createRef(initialValue: TValue) { + return { + current: initialValue, + }; +} diff --git a/packages/autocomplete-shared/src/index.ts b/packages/autocomplete-shared/src/index.ts index b8c0b5eeb..6e8a9b12b 100644 --- a/packages/autocomplete-shared/src/index.ts +++ b/packages/autocomplete-shared/src/index.ts @@ -1,2 +1,3 @@ +export * from './createRef'; export * from './warn'; export * from './MaybePromise'; diff --git a/packages/website/docs/autocomplete-js.md b/packages/website/docs/autocomplete-js.md index 4b5a265f5..f0eb114c1 100644 --- a/packages/website/docs/autocomplete-js.md +++ b/packages/website/docs/autocomplete-js.md @@ -69,12 +69,18 @@ import { autocomplete } from '@algolia/autocomplete-js'; > `string | HTMLElement` | **required** -The container for the autocomplete search box. You can either pass a [CSS selector](https://developer.mozilla.org/docs/Web/CSS/CSS_Selectors) or an [Element](https://developer.mozilla.org/docs/Web/API/HTMLElement). The first element matching the provided selector will be used as container. +The container for the Autocomplete search box. You can either pass a [CSS selector](https://developer.mozilla.org/docs/Web/CSS/CSS_Selectors) or an [Element](https://developer.mozilla.org/docs/Web/API/HTMLElement). The first element matching the provided selector will be used as container. import CreateAutocompleteProps from './partials/createAutocomplete-props.md' +### `panelContainer` + +> `string | HTMLElement` + +The container for the Autocomplete panel. You can either pass a [CSS selector](https://developer.mozilla.org/docs/Web/CSS/CSS_Selectors) or an [Element](https://developer.mozilla.org/docs/Web/API/HTMLElement). The first element matching the provided selector will be used as container. + ### `panelPlacement` > `"start" | "end" | "full-width" | "input-wrapper-width" | defaults to `"input-wrapper-width"`