From 4b365b1155f362c3361e6f0fdcdc1852557fe589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Fri, 30 Apr 2021 13:55:35 +0200 Subject: [PATCH 1/3] feat(js): sync detached open state This synchronizes the internal open state with the detached overlay open state. --- .../src/__tests__/detached.test.ts | 98 +++++++++++++++++++ packages/autocomplete-js/src/autocomplete.ts | 43 ++++++-- .../src/createAutocompleteDom.ts | 33 ++----- 3 files changed, 142 insertions(+), 32 deletions(-) create mode 100644 packages/autocomplete-js/src/__tests__/detached.test.ts diff --git a/packages/autocomplete-js/src/__tests__/detached.test.ts b/packages/autocomplete-js/src/__tests__/detached.test.ts new file mode 100644 index 000000000..d9d33e1fc --- /dev/null +++ b/packages/autocomplete-js/src/__tests__/detached.test.ts @@ -0,0 +1,98 @@ +import { fireEvent, waitFor } from '@testing-library/dom'; + +import { autocomplete } from '../autocomplete'; + +describe('detached', () => { + const originalMatchMedia = window.matchMedia; + + beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn((query) => ({ + matches: true, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); + }); + + afterAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: originalMatchMedia, + }); + }); + + test('closes after onSelect', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + autocomplete<{ label: string }>({ + id: 'autocomplete', + detachedMediaQuery: '', + container, + getSources() { + return [ + { + sourceId: 'testSource', + getItems() { + return [ + { label: 'Item 1' }, + { label: 'Item 2' }, + { label: 'Item 3' }, + ]; + }, + templates: { + item({ item }) { + return item.label; + }, + }, + }, + ]; + }, + }); + + const searchButton = container.querySelector( + '.aa-DetachedSearchButton' + ); + + // Open detached overlay + searchButton.click(); + + await waitFor(() => { + const input = document.querySelector('.aa-Input'); + + expect(document.querySelector('.aa-DetachedOverlay')).toBeInTheDocument(); + expect(document.body).toHaveClass('aa-Detached'); + expect(input).toHaveFocus(); + + fireEvent.input(input, { target: { value: 'a' } }); + }); + + // Wait for the panel to open + await waitFor(() => { + expect( + document.querySelector('.aa-Panel') + ).toBeInTheDocument(); + }); + + const firstItem = document.querySelector( + '#autocomplete-item-0' + ); + + // Select the first item + firstItem.click(); + + // The detached overlay should close + await waitFor(() => { + expect( + document.querySelector('.aa-DetachedOverlay') + ).not.toBeInTheDocument(); + expect(document.body).not.toHaveClass('aa-Detached'); + }); + }); +}); diff --git a/packages/autocomplete-js/src/autocomplete.ts b/packages/autocomplete-js/src/autocomplete.ts index f03c021e9..86fc60209 100644 --- a/packages/autocomplete-js/src/autocomplete.ts +++ b/packages/autocomplete-js/src/autocomplete.ts @@ -46,17 +46,28 @@ export function autocomplete( const autocomplete = reactive(() => createAutocomplete({ ...props.value.core, - onStateChange(options) { - hasNoResultsSourceTemplateRef.current = options.state.collections.some( + onStateChange(params) { + if ( + isDetached.value && + params.prevState.isOpen !== params.state.isOpen + ) { + setIsModalOpen(params.state.isOpen); + } + + hasNoResultsSourceTemplateRef.current = params.state.collections.some( (collection) => (collection.source as AutocompleteSource).templates.noResults ); - onStateChangeRef.current?.(options as any); - props.value.core.onStateChange?.(options as any); + onStateChangeRef.current?.(params as any); + props.value.core.onStateChange?.(params as any); }, shouldPanelOpen: optionsRef.current.shouldPanelOpen || (({ state }) => { + if (isDetached.value) { + return true; + } + const hasItems = getItemsCount(state) > 0; if (!props.value.core.openOnFocus && !state.query) { @@ -111,6 +122,7 @@ export function autocomplete( isDetached: isDetached.value, placeholder: props.value.core.placeholder, propGetters, + setIsModalOpen, state: lastStateRef.current, }) ); @@ -188,7 +200,7 @@ export function autocomplete( : dom.value.panel; if (isDetached.value && lastStateRef.current.isOpen) { - dom.value.openDetachedOverlay(); + setIsModalOpen(true); } scheduleRender(lastStateRef.current); @@ -221,7 +233,7 @@ export function autocomplete( // 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) { + if (!isDetached.value && state.isOpen && !prevState.isOpen) { setPanelPosition(); } @@ -325,6 +337,25 @@ export function autocomplete( }); } + function setIsModalOpen(value: boolean) { + const prevValue = document.body.contains(dom.value.detachedOverlay); + + if (value === prevValue) { + return; + } + + if (value) { + document.body.appendChild(dom.value.detachedOverlay); + document.body.classList.add('aa-Detached'); + dom.value.input.focus(); + } else { + document.body.removeChild(dom.value.detachedOverlay); + document.body.classList.remove('aa-Detached'); + autocomplete.value.setQuery(''); + autocomplete.value.refresh(); + } + } + return { ...autocompleteScopeApi, update, diff --git a/packages/autocomplete-js/src/createAutocompleteDom.ts b/packages/autocomplete-js/src/createAutocompleteDom.ts index b8b2dd906..0eb2b128b 100644 --- a/packages/autocomplete-js/src/createAutocompleteDom.ts +++ b/packages/autocomplete-js/src/createAutocompleteDom.ts @@ -21,13 +21,10 @@ type CreateDomProps = { isDetached: boolean; placeholder?: string; propGetters: AutocompletePropGetters; + setIsModalOpen(value: boolean): void; state: AutocompleteState; }; -type CreateAutocompleteDomReturn = AutocompleteDom & { - openDetachedOverlay(): void; -}; - export function createAutocompleteDom({ autocomplete, autocompleteScopeApi, @@ -35,15 +32,9 @@ export function createAutocompleteDom({ isDetached, placeholder = 'Search', propGetters, + setIsModalOpen, state, -}: CreateDomProps): CreateAutocompleteDomReturn { - function onDetachedOverlayClose() { - autocomplete.setQuery(''); - autocomplete.setIsOpen(false); - autocomplete.refresh(); - document.body.classList.remove('aa-Detached'); - } - +}: CreateDomProps): AutocompleteDom { const rootProps = propGetters.getRootProps({ state, props: autocomplete.getRootProps({}), @@ -63,8 +54,7 @@ export function createAutocompleteDom({ class: classNames.detachedOverlay, children: [detachedContainer], onMouseDown() { - document.body.removeChild(detachedOverlay); - onDetachedOverlayClose(); + setIsModalOpen(false); }, }); @@ -103,8 +93,7 @@ export function createAutocompleteDom({ autocompleteScopeApi, onDetachedEscape: isDetached ? () => { - document.body.removeChild(detachedOverlay); - onDetachedOverlayClose(); + setIsModalOpen(false); } : undefined, }); @@ -148,12 +137,6 @@ export function createAutocompleteDom({ }); } - function openDetachedOverlay() { - document.body.appendChild(detachedOverlay); - document.body.classList.add('aa-Detached'); - input.focus(); - } - if (isDetached) { const detachedSearchButtonIcon = createDomElement('div', { class: classNames.detachedSearchButtonIcon, @@ -167,7 +150,7 @@ export function createAutocompleteDom({ class: classNames.detachedSearchButton, onClick(event: MouseEvent) { event.preventDefault(); - openDetachedOverlay(); + setIsModalOpen(true); }, children: [detachedSearchButtonIcon, detachedSearchButtonPlaceholder], }); @@ -175,8 +158,7 @@ export function createAutocompleteDom({ class: classNames.detachedCancelButton, textContent: 'Cancel', onClick() { - document.body.removeChild(detachedOverlay); - onDetachedOverlayClose(); + setIsModalOpen(false); }, }); const detachedFormContainer = createDomElement('div', { @@ -191,7 +173,6 @@ export function createAutocompleteDom({ } return { - openDetachedOverlay, detachedContainer, detachedOverlay, inputWrapper, From ee1845a14be30f68410135a34944dd963b3cef22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Fri, 30 Apr 2021 20:35:45 +0200 Subject: [PATCH 2/3] fix(js): avoid Uncaught TypeError with non-existant child node --- packages/autocomplete-js/src/autocomplete.ts | 42 +++++++++---------- .../src/createAutocompleteDom.ts | 3 ++ .../autocomplete-js/src/elements/Input.ts | 1 + 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/packages/autocomplete-js/src/autocomplete.ts b/packages/autocomplete-js/src/autocomplete.ts index 86fc60209..ae408a50c 100644 --- a/packages/autocomplete-js/src/autocomplete.ts +++ b/packages/autocomplete-js/src/autocomplete.ts @@ -47,13 +47,6 @@ export function autocomplete( createAutocomplete({ ...props.value.core, onStateChange(params) { - if ( - isDetached.value && - params.prevState.isOpen !== params.state.isOpen - ) { - setIsModalOpen(params.state.isOpen); - } - hasNoResultsSourceTemplateRef.current = params.state.collections.some( (collection) => (collection.source as AutocompleteSource).templates.noResults @@ -229,6 +222,10 @@ export function autocomplete( }, 0); onStateChangeRef.current = ({ state, prevState }) => { + if (isDetached.value && prevState.isOpen !== state.isOpen) { + setIsModalOpen(state.isOpen); + } + // 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 @@ -338,22 +335,25 @@ export function autocomplete( } function setIsModalOpen(value: boolean) { - const prevValue = document.body.contains(dom.value.detachedOverlay); + requestAnimationFrame(() => { + const prevValue = document.body.contains(dom.value.detachedOverlay); - if (value === prevValue) { - return; - } + if (value === prevValue) { + return; + } - if (value) { - document.body.appendChild(dom.value.detachedOverlay); - document.body.classList.add('aa-Detached'); - dom.value.input.focus(); - } else { - document.body.removeChild(dom.value.detachedOverlay); - document.body.classList.remove('aa-Detached'); - autocomplete.value.setQuery(''); - autocomplete.value.refresh(); - } + if (value) { + document.body.appendChild(dom.value.detachedOverlay); + document.body.classList.add('aa-Detached'); + dom.value.input.focus(); + } else { + document.body.removeChild(dom.value.detachedOverlay); + + document.body.classList.remove('aa-Detached'); + autocomplete.value.setQuery(''); + autocomplete.value.refresh(); + } + }); } return { diff --git a/packages/autocomplete-js/src/createAutocompleteDom.ts b/packages/autocomplete-js/src/createAutocompleteDom.ts index 0eb2b128b..89caf4cc4 100644 --- a/packages/autocomplete-js/src/createAutocompleteDom.ts +++ b/packages/autocomplete-js/src/createAutocompleteDom.ts @@ -55,6 +55,7 @@ export function createAutocompleteDom({ children: [detachedContainer], onMouseDown() { setIsModalOpen(false); + autocomplete.setIsOpen(false); }, }); @@ -93,6 +94,7 @@ export function createAutocompleteDom({ autocompleteScopeApi, onDetachedEscape: isDetached ? () => { + autocomplete.setIsOpen(false); setIsModalOpen(false); } : undefined, @@ -158,6 +160,7 @@ export function createAutocompleteDom({ class: classNames.detachedCancelButton, textContent: 'Cancel', onClick() { + autocomplete.setIsOpen(false); setIsModalOpen(false); }, }); diff --git a/packages/autocomplete-js/src/elements/Input.ts b/packages/autocomplete-js/src/elements/Input.ts index be9d6cca9..9de221da7 100644 --- a/packages/autocomplete-js/src/elements/Input.ts +++ b/packages/autocomplete-js/src/elements/Input.ts @@ -37,6 +37,7 @@ export const Input: AutocompleteElement = ({ ...inputProps, onKeyDown(event: KeyboardEvent) { if (onDetachedEscape && event.key === 'Escape') { + event.preventDefault(); onDetachedEscape(); return; } From 7f5dab00644dd4d3b967c41e6969c4b4b61f3f17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Fri, 30 Apr 2021 21:23:07 +0200 Subject: [PATCH 3/3] chore(js): remove empty line --- packages/autocomplete-js/src/autocomplete.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/autocomplete-js/src/autocomplete.ts b/packages/autocomplete-js/src/autocomplete.ts index ae408a50c..ef3839d7c 100644 --- a/packages/autocomplete-js/src/autocomplete.ts +++ b/packages/autocomplete-js/src/autocomplete.ts @@ -348,7 +348,6 @@ export function autocomplete( dom.value.input.focus(); } else { document.body.removeChild(dom.value.detachedOverlay); - document.body.classList.remove('aa-Detached'); autocomplete.value.setQuery(''); autocomplete.value.refresh();