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..ef3839d7c 100644 --- a/packages/autocomplete-js/src/autocomplete.ts +++ b/packages/autocomplete-js/src/autocomplete.ts @@ -46,17 +46,21 @@ export function autocomplete( const autocomplete = reactive(() => createAutocomplete({ ...props.value.core, - onStateChange(options) { - hasNoResultsSourceTemplateRef.current = options.state.collections.some( + onStateChange(params) { + 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 +115,7 @@ export function autocomplete( isDetached: isDetached.value, placeholder: props.value.core.placeholder, propGetters, + setIsModalOpen, state: lastStateRef.current, }) ); @@ -188,7 +193,7 @@ export function autocomplete( : dom.value.panel; if (isDetached.value && lastStateRef.current.isOpen) { - dom.value.openDetachedOverlay(); + setIsModalOpen(true); } scheduleRender(lastStateRef.current); @@ -217,11 +222,15 @@ 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 // it again. - if (state.isOpen && !prevState.isOpen) { + if (!isDetached.value && state.isOpen && !prevState.isOpen) { setPanelPosition(); } @@ -325,6 +334,27 @@ export function autocomplete( }); } + function setIsModalOpen(value: boolean) { + requestAnimationFrame(() => { + 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..89caf4cc4 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,8 @@ export function createAutocompleteDom({ class: classNames.detachedOverlay, children: [detachedContainer], onMouseDown() { - document.body.removeChild(detachedOverlay); - onDetachedOverlayClose(); + setIsModalOpen(false); + autocomplete.setIsOpen(false); }, }); @@ -103,8 +94,8 @@ export function createAutocompleteDom({ autocompleteScopeApi, onDetachedEscape: isDetached ? () => { - document.body.removeChild(detachedOverlay); - onDetachedOverlayClose(); + autocomplete.setIsOpen(false); + setIsModalOpen(false); } : undefined, }); @@ -148,12 +139,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 +152,7 @@ export function createAutocompleteDom({ class: classNames.detachedSearchButton, onClick(event: MouseEvent) { event.preventDefault(); - openDetachedOverlay(); + setIsModalOpen(true); }, children: [detachedSearchButtonIcon, detachedSearchButtonPlaceholder], }); @@ -175,8 +160,8 @@ export function createAutocompleteDom({ class: classNames.detachedCancelButton, textContent: 'Cancel', onClick() { - document.body.removeChild(detachedOverlay); - onDetachedOverlayClose(); + autocomplete.setIsOpen(false); + setIsModalOpen(false); }, }); const detachedFormContainer = createDomElement('div', { @@ -191,7 +176,6 @@ export function createAutocompleteDom({ } return { - openDetachedOverlay, detachedContainer, detachedOverlay, inputWrapper, 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; }