From 307a7acc4283e10a19cb7d067f04f1bea79dc56f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Tue, 24 Nov 2020 15:11:40 +0100 Subject: [PATCH] fix(js): calculate panel position before opening (#375) --- .eslintrc.js | 1 + babel.config.js | 4 + examples/js/env.ts | 1 + global.d.ts | 1 + jest.config.js | 1 + packages/autocomplete-js/global.d.ts | 1 + .../__tests__/fixtures/query-suggestions.json | 387 ++++++++++++++++++ .../src/__tests__/positioning.test.ts | 163 ++++++++ packages/autocomplete-js/src/autocomplete.ts | 18 +- .../autocomplete-js/src/components/Panel.ts | 6 + .../src/getPanelPositionStyle.ts | 21 +- 11 files changed, 594 insertions(+), 10 deletions(-) create mode 100644 packages/autocomplete-js/global.d.ts create mode 100644 packages/autocomplete-js/src/__tests__/fixtures/query-suggestions.json create mode 100644 packages/autocomplete-js/src/__tests__/positioning.test.ts diff --git a/.eslintrc.js b/.eslintrc.js index 9f247918a..33351b493 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -2,6 +2,7 @@ module.exports = { extends: ['algolia', 'algolia/jest', 'algolia/react', 'algolia/typescript'], globals: { __DEV__: false, + __TEST__: false, }, settings: { react: { diff --git a/babel.config.js b/babel.config.js index f613e64d5..de74414e1 100644 --- a/babel.config.js +++ b/babel.config.js @@ -31,6 +31,10 @@ module.exports = (api) => { type: 'node', replacement: "process.env.NODE_ENV !== 'production'", }, + __TEST__: { + type: 'node', + replacement: "process.env.NODE_ENV === 'test'", + }, }, ], ]), diff --git a/examples/js/env.ts b/examples/js/env.ts index 9ee195763..b6dc0beb2 100644 --- a/examples/js/env.ts +++ b/examples/js/env.ts @@ -3,3 +3,4 @@ // We therefore need to manually override it in the example app. // See https://twitter.com/devongovett/status/1134231234605830144 (global as any).__DEV__ = process.env.NODE_ENV !== 'production'; +(global as any).__TEST__ = false; diff --git a/global.d.ts b/global.d.ts index b867229b4..fb3e7d32e 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1 +1,2 @@ declare const __DEV__: boolean; +declare const __TEST__: boolean; diff --git a/jest.config.js b/jest.config.js index f2da33719..cab9d4abf 100644 --- a/jest.config.js +++ b/jest.config.js @@ -11,5 +11,6 @@ module.exports = { ], globals: { __DEV__: true, + __TEST__: true, }, }; diff --git a/packages/autocomplete-js/global.d.ts b/packages/autocomplete-js/global.d.ts new file mode 100644 index 000000000..9b2570650 --- /dev/null +++ b/packages/autocomplete-js/global.d.ts @@ -0,0 +1 @@ +declare const __TEST__: boolean; diff --git a/packages/autocomplete-js/src/__tests__/fixtures/query-suggestions.json b/packages/autocomplete-js/src/__tests__/fixtures/query-suggestions.json new file mode 100644 index 000000000..e12d76ba5 --- /dev/null +++ b/packages/autocomplete-js/src/__tests__/fixtures/query-suggestions.json @@ -0,0 +1,387 @@ +[ + { + "instant_search": { + "exact_nb_hits": 260, + "facets": { + "exact_matches": { + "categories": [ + { + "value": "Appliances", + "count": 252 + }, + { + "value": "Ranges, Cooktops & Ovens", + "count": 229 + } + ], + "hierarchicalCategories.lvl0": [ + { + "value": "Appliances", + "count": 252 + } + ], + "hierarchicalCategories.lvl1": [ + { + "value": "Appliances > Ranges, Cooktops & Ovens", + "count": 229 + } + ], + "hierarchicalCategories.lvl2": [ + { + "value": "Appliances > Ranges, Cooktops & Ovens > Cooktops", + "count": 137 + } + ] + }, + "analytics": { + "categories": [], + "hierarchicalCategories.lvl0": [], + "hierarchicalCategories.lvl1": [ + { + "attribute": "hierarchicalCategories.lvl1", + "operator": ":", + "value": "Appliances > Ranges, Cooktops & Ovens", + "count": 1756 + } + ], + "hierarchicalCategories.lvl2": [] + } + } + }, + "nb_words": 1, + "popularity": 1230, + "query": "cooktop", + "objectID": "cooktop", + "_highlightResult": { + "query": { + "value": "cooktop", + "matchLevel": "none", + "matchedWords": [] + } + } + }, + { + "instant_search": { + "exact_nb_hits": 151, + "facets": { + "exact_matches": { + "categories": [ + { + "value": "Computers & Tablets", + "count": 142 + }, + { + "value": "Laptop Accessories", + "count": 106 + } + ], + "hierarchicalCategories.lvl0": [ + { + "value": "Computers & Tablets", + "count": 142 + } + ], + "hierarchicalCategories.lvl1": [ + { + "value": "Computers & Tablets > Laptop Accessories", + "count": 106 + } + ], + "hierarchicalCategories.lvl2": [ + { + "value": "Computers & Tablets > Laptop Accessories > Laptop Bags & Cases", + "count": 88 + } + ] + }, + "analytics": { + "categories": [], + "hierarchicalCategories.lvl0": [], + "hierarchicalCategories.lvl1": [], + "hierarchicalCategories.lvl2": [] + } + } + }, + "nb_words": 1, + "popularity": 700, + "query": "macbook", + "objectID": "macbook", + "_highlightResult": { + "query": { + "value": "macbook", + "matchLevel": "none", + "matchedWords": [] + } + } + }, + { + "instant_search": { + "exact_nb_hits": 1179, + "facets": { + "exact_matches": { + "categories": [ + { + "value": "Cell Phones", + "count": 385 + }, + { + "value": "Cell Phone Accessories", + "count": 237 + } + ], + "hierarchicalCategories.lvl0": [ + { + "value": "Cell Phones", + "count": 385 + } + ], + "hierarchicalCategories.lvl1": [ + { + "value": "Cell Phones > Cell Phone Accessories", + "count": 237 + } + ], + "hierarchicalCategories.lvl2": [ + { + "value": "Cameras & Camcorders > Digital Camera Accessories > Camera Batteries & Power", + "count": 111 + } + ] + }, + "analytics": { + "categories": [], + "hierarchicalCategories.lvl0": [], + "hierarchicalCategories.lvl1": [], + "hierarchicalCategories.lvl2": [] + } + } + }, + "nb_words": 1, + "popularity": 536, + "query": "battery", + "objectID": "battery", + "_highlightResult": { + "query": { + "value": "battery", + "matchLevel": "none", + "matchedWords": [] + } + } + }, + { + "instant_search": { + "exact_nb_hits": 79, + "facets": { + "exact_matches": { + "categories": [ + { + "value": "Computers & Tablets", + "count": 52 + }, + { + "value": "Tablets", + "count": 23 + } + ], + "hierarchicalCategories.lvl0": [ + { + "value": "Computers & Tablets", + "count": 52 + } + ], + "hierarchicalCategories.lvl1": [ + { + "value": "Computers & Tablets > Tablets", + "count": 23 + } + ], + "hierarchicalCategories.lvl2": [ + { + "value": "Computers & Tablets > Tablets > All Tablets", + "count": 22 + } + ] + }, + "analytics": { + "categories": [], + "hierarchicalCategories.lvl0": [], + "hierarchicalCategories.lvl1": [], + "hierarchicalCategories.lvl2": [] + } + } + }, + "nb_words": 1, + "popularity": 407, + "query": "amazon", + "objectID": "amazon", + "_highlightResult": { + "query": { + "value": "amazon", + "matchLevel": "none", + "matchedWords": [] + } + } + }, + { + "instant_search": { + "exact_nb_hits": 116, + "facets": { + "exact_matches": { + "categories": [ + { + "value": "Cell Phones", + "count": 54 + }, + { + "value": "Cell Phone Accessories", + "count": 41 + } + ], + "hierarchicalCategories.lvl0": [ + { + "value": "Cell Phones", + "count": 54 + } + ], + "hierarchicalCategories.lvl1": [ + { + "value": "Cell Phones > Cell Phone Accessories", + "count": 41 + } + ], + "hierarchicalCategories.lvl2": [ + { + "value": "Cell Phones > Cell Phone Accessories > Cell Phone Cases & Clips", + "count": 29 + } + ] + }, + "analytics": { + "categories": [], + "hierarchicalCategories.lvl0": [], + "hierarchicalCategories.lvl1": [], + "hierarchicalCategories.lvl2": [] + } + } + }, + "nb_words": 1, + "popularity": 149, + "query": "google", + "objectID": "google", + "_highlightResult": { + "query": { + "value": "google", + "matchLevel": "none", + "matchedWords": [] + } + } + }, + { + "instant_search": { + "exact_nb_hits": 1205, + "facets": { + "exact_matches": { + "categories": [ + { + "value": "Cell Phones", + "count": 639 + }, + { + "value": "Cell Phone Accessories", + "count": 554 + } + ], + "hierarchicalCategories.lvl0": [ + { + "value": "Cell Phones", + "count": 639 + } + ], + "hierarchicalCategories.lvl1": [ + { + "value": "Cell Phones > Cell Phone Accessories", + "count": 554 + } + ], + "hierarchicalCategories.lvl2": [ + { + "value": "Cell Phones > Cell Phone Accessories > Cell Phone Cases & Clips", + "count": 450 + } + ] + }, + "analytics": { + "categories": [], + "hierarchicalCategories.lvl0": [], + "hierarchicalCategories.lvl1": [], + "hierarchicalCategories.lvl2": [] + } + } + }, + "nb_words": 1, + "popularity": 125, + "query": "samsung", + "objectID": "samsung", + "_highlightResult": { + "query": { + "value": "samsung", + "matchLevel": "none", + "matchedWords": [] + } + } + }, + { + "instant_search": { + "exact_nb_hits": 1659, + "facets": { + "exact_matches": { + "categories": [ + { + "value": "Cell Phones", + "count": 1509 + }, + { + "value": "Cell Phone Accessories", + "count": 1370 + } + ], + "hierarchicalCategories.lvl0": [ + { + "value": "Cell Phones", + "count": 1509 + } + ], + "hierarchicalCategories.lvl1": [ + { + "value": "Cell Phones > Cell Phone Accessories", + "count": 1370 + } + ], + "hierarchicalCategories.lvl2": [ + { + "value": "Cell Phones > Cell Phone Accessories > Cell Phone Cases & Clips", + "count": 607 + } + ] + }, + "analytics": { + "categories": [], + "hierarchicalCategories.lvl0": [], + "hierarchicalCategories.lvl1": [], + "hierarchicalCategories.lvl2": [] + } + } + }, + "nb_words": 1, + "popularity": 124, + "query": "iphone", + "objectID": "iphone", + "_highlightResult": { + "query": { + "value": "iphone", + "matchLevel": "none", + "matchedWords": [] + } + } + } +] diff --git a/packages/autocomplete-js/src/__tests__/positioning.test.ts b/packages/autocomplete-js/src/__tests__/positioning.test.ts new file mode 100644 index 000000000..18751358b --- /dev/null +++ b/packages/autocomplete-js/src/__tests__/positioning.test.ts @@ -0,0 +1,163 @@ +import { AutocompletePlugin } from '@algolia/autocomplete-core'; +import { waitFor, getByTestId } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; + +import { autocomplete } from '../'; + +import querySuggestions from './fixtures/query-suggestions.json'; + +type QuerySuggestionFacetMatch = { + value: string; + count: number; +}; +type QuerySuggestionsHit = { + instant_search: { + exact_nb_hits: number; + facets: { + exact_matches: { + categories: QuerySuggestionFacetMatch[]; + 'hierarchicalCategories.lvl0': QuerySuggestionFacetMatch[]; + 'hierarchicalCategories.lvl1': QuerySuggestionFacetMatch[]; + 'hierarchicalCategories.lvl2': QuerySuggestionFacetMatch[]; + }; + }; + }; + nb_words: number; + popularity: number; + query: string; + objectID: string; +}; + +const querySuggestionsFixturePlugin: AutocompletePlugin< + QuerySuggestionsHit, + undefined +> = { + getSources() { + return [ + { + getItems() { + return querySuggestions; + }, + templates: { + item({ item }) { + return item.query; + }, + }, + }, + ]; + }, +}; + +describe('Panel positioning', () => { + const rootPosition = { + bottom: 0, + height: 40, + left: 300, + right: 990, + top: 20, + width: 600, + x: 300, + y: 40, + }; + const formPosition = { + bottom: 0, + height: 40, + left: 300, + right: 990, + top: 20, + width: 600, + x: 300, + y: 40, + }; + + beforeAll(() => { + Object.defineProperty(document.documentElement, 'clientWidth', { + writable: true, + configurable: true, + value: 1920, + }); + Object.defineProperty(document.documentElement, 'clientHeight', { + writable: true, + configurable: true, + value: 1080, + }); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('positions the panel below the root element', async () => { + const container = document.createElement('div'); + const panelContainer = document.body; + document.body.appendChild(container); + + autocomplete({ + id: 'autocomplete-0', + container, + panelContainer, + plugins: [querySuggestionsFixturePlugin], + }); + + const root = document.querySelector('.aa-Autocomplete'); + root.getBoundingClientRect = jest.fn().mockReturnValue(rootPosition); + const form = document.querySelector('.aa-Form'); + form.getBoundingClientRect = jest.fn().mockReturnValue(formPosition); + + const input = document.querySelector('.aa-Input'); + userEvent.type(input, 'a'); + + await waitFor(() => getByTestId(panelContainer, 'panel')); + + expect(getByTestId(panelContainer, 'panel')).toHaveStyle({ + top: '60px', + left: '300px', + right: '1020px', + }); + }); + + test('repositions the panel below the root element after a UI change', async () => { + const container = document.createElement('div'); + const panelContainer = document.body; + document.body.appendChild(container); + + autocomplete({ + id: 'autocomplete-0', + container, + panelContainer, + plugins: [querySuggestionsFixturePlugin], + }); + + const root = document.querySelector('.aa-Autocomplete'); + root.getBoundingClientRect = jest.fn().mockReturnValue(rootPosition); + const form = document.querySelector('.aa-Form'); + form.getBoundingClientRect = jest.fn().mockReturnValue(formPosition); + + const input = document.querySelector('.aa-Input'); + userEvent.type(input, 'a'); + + await waitFor(() => getByTestId(panelContainer, 'panel')); + + expect(getByTestId(panelContainer, 'panel')).toHaveStyle({ + top: '60px', + left: '300px', + right: '1020px', + }); + + input.blur(); + + // Move the root vertically + root.getBoundingClientRect = jest.fn().mockReturnValue({ + ...rootPosition, + top: 40, + }); + + input.focus(); + + expect(getByTestId(panelContainer, 'panel')).toHaveStyle({ + top: '80px', + left: '300px', + right: '1020px', + }); + }); +}); diff --git a/packages/autocomplete-js/src/autocomplete.ts b/packages/autocomplete-js/src/autocomplete.ts index cc9bd3519..6b804687e 100644 --- a/packages/autocomplete-js/src/autocomplete.ts +++ b/packages/autocomplete-js/src/autocomplete.ts @@ -60,7 +60,7 @@ export function autocomplete({ style: getPanelPositionStyle({ panelPlacement, container: root, - inputWrapper, + form, environment: props.environment, }), }); @@ -98,7 +98,9 @@ export function autocomplete({ // As an example: // - without debouncing: "iphone case" query → 85 renders // - with debouncing: "iphone case" query → 12 renders - onStateChangeRef.current = debounce(({ state }) => { + const debouncedOnStateChange = debounce<{ + state: AutocompleteState; + }>(({ state }) => { unmountRef.current = render(renderer, { state, ...autocomplete, @@ -114,6 +116,18 @@ export function autocomplete({ }); }, 0); + onStateChangeRef.current = ({ prevState, state }) => { + // The outer DOM might have changed since the last time the panel was + // positioned. The layout might have shifted vertically for instance. + // It's therefore safer to re-calculate the panel position before opening + // it again. + if (state.isOpen && !prevState.isOpen) { + setPanelPosition(); + } + + return debouncedOnStateChange({ state }); + }; + return () => { unmountRef.current?.(); onStateChangeRef.current = undefined; diff --git a/packages/autocomplete-js/src/components/Panel.ts b/packages/autocomplete-js/src/components/Panel.ts index 71a7d4ab4..f24dadd51 100644 --- a/packages/autocomplete-js/src/components/Panel.ts +++ b/packages/autocomplete-js/src/components/Panel.ts @@ -17,5 +17,11 @@ export const Panel: Component = ({ class: concatClassNames(['aa-Panel', classNames.panel]), }); + if (__TEST__) { + setProperties(element, { + 'data-testid': 'panel', + }); + } + return element; }; diff --git a/packages/autocomplete-js/src/getPanelPositionStyle.ts b/packages/autocomplete-js/src/getPanelPositionStyle.ts index 35c705205..a5a633714 100644 --- a/packages/autocomplete-js/src/getPanelPositionStyle.ts +++ b/packages/autocomplete-js/src/getPanelPositionStyle.ts @@ -1,14 +1,19 @@ import { AutocompleteOptions } from './types'; +type GetPanelPositionStyleParams = Pick< + AutocompleteOptions, + 'panelPlacement' | 'environment' +> & { + container: HTMLElement; + form: HTMLElement; +}; + export function getPanelPositionStyle({ panelPlacement, container, - inputWrapper, + form, environment = window, -}: Partial> & { - container: HTMLElement; - inputWrapper: HTMLElement; -}) { +}: GetPanelPositionStyleParams) { const containerRect = container.getBoundingClientRect(); const top = containerRect.top + containerRect.height; @@ -42,14 +47,14 @@ export function getPanelPositionStyle({ } case 'input-wrapper-width': { - const inputWrapperRect = inputWrapper.getBoundingClientRect(); + const formRect = form.getBoundingClientRect(); return { top, - left: inputWrapperRect.left, + left: formRect.left, right: environment.document.documentElement.clientWidth - - (inputWrapperRect.left + inputWrapperRect.width), + (formRect.left + formRect.width), // @TODO [IE support] IE doesn't support `"unset"` // See https://caniuse.com/#feat=css-unset-value width: 'unset',