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"`