diff --git a/packages/autocomplete-core/src/__tests__/concurrency.test.ts b/packages/autocomplete-core/src/__tests__/concurrency.test.ts index de7dde305..14616d59f 100644 --- a/packages/autocomplete-core/src/__tests__/concurrency.test.ts +++ b/packages/autocomplete-core/src/__tests__/concurrency.test.ts @@ -134,7 +134,7 @@ describe('concurrency', () => { expect(getSources).toHaveBeenCalledTimes(3); }); - test('keeps the panel closed on blur', async () => { + test('keeps the panel closed on Enter', async () => { const onStateChange = jest.fn(); const { timeout, delayedGetSources } = createDelayedGetSources({ sources: [100, 200], @@ -188,7 +188,74 @@ describe('concurrency', () => { expect(getSources).toHaveBeenCalledTimes(2); }); - test('keeps the panel closed on touchstart blur', async () => { + test('keeps the panel closed on click outside', async () => { + const onStateChange = jest.fn(); + const { timeout, delayedGetSources } = createDelayedGetSources({ + sources: [100, 200], + }); + const getSources = jest.fn(delayedGetSources); + + const { + inputElement, + getEnvironmentProps, + formElement, + } = createPlayground(createAutocomplete, { + onStateChange, + getSources, + }); + + const panelElement = document.createElement('div'); + + const { onMouseDown } = getEnvironmentProps({ + inputElement, + formElement, + panelElement, + }); + window.addEventListener('mousedown', onMouseDown); + + userEvent.type(inputElement, 'a'); + + await runAllMicroTasks(); + + // The search request is triggered + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + status: 'loading', + query: 'a', + }), + }) + ); + + userEvent.click(document.body); + + // The status is immediately set to "idle" and the panel is closed + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + status: 'idle', + isOpen: false, + query: 'a', + }), + }) + ); + + await defer(noop, timeout); + + // Once the request is settled, the state remains unchanged + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + status: 'idle', + isOpen: false, + }), + }) + ); + + expect(getSources).toHaveBeenCalledTimes(1); + }); + + test('keeps the panel closed on touchstart', async () => { const onStateChange = jest.fn(); const { timeout, delayedGetSources } = createDelayedGetSources({ sources: [100, 200], diff --git a/packages/autocomplete-core/src/__tests__/getEnvironmentProps.test.ts b/packages/autocomplete-core/src/__tests__/getEnvironmentProps.test.ts index 788688ab6..739e5d928 100644 --- a/packages/autocomplete-core/src/__tests__/getEnvironmentProps.test.ts +++ b/packages/autocomplete-core/src/__tests__/getEnvironmentProps.test.ts @@ -1,3 +1,5 @@ +import userEvent from '@testing-library/user-event'; + import { createPlayground, createSource, @@ -29,6 +31,202 @@ describe('getEnvironmentProps', () => { ); }); + describe('onMouseDown', () => { + test('is a noop when panel is not open and status is idle', () => { + const onStateChange = jest.fn(); + const { + getEnvironmentProps, + inputElement, + formElement, + } = createPlayground(createAutocomplete, { onStateChange }); + const panelElement = document.createElement('div'); + + const { onMouseDown } = getEnvironmentProps({ + inputElement, + formElement, + panelElement, + }); + window.addEventListener('mousedown', onMouseDown); + + // Dispatch MouseDown event on window + const customEvent = new CustomEvent('mousedown', { bubbles: true }); + window.dispatchEvent(customEvent); + + expect(onStateChange).not.toHaveBeenCalled(); + + window.removeEventListener('mousedown', onMouseDown); + }); + + test('is a noop when the event target is the input element', async () => { + const onStateChange = jest.fn(); + const { + getEnvironmentProps, + inputElement, + formElement, + } = createPlayground(createAutocomplete, { + onStateChange, + openOnFocus: true, + getSources() { + return [ + createSource({ + getItems: () => [{ label: '1' }], + }), + ]; + }, + }); + const panelElement = document.createElement('div'); + + const { onMouseDown } = getEnvironmentProps({ + inputElement, + formElement, + panelElement, + }); + window.addEventListener('mousedown', onMouseDown); + + // Click input (focuses it, which opens the panel) + userEvent.click(inputElement); + + await runAllMicroTasks(); + + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + isOpen: true, + }), + }) + ); + + onStateChange.mockClear(); + + // Dispatch MouseDown event on the input (bubbles to window) + const customEvent = new CustomEvent('mousedown', { bubbles: true }); + inputElement.dispatchEvent(customEvent); + + await runAllMicroTasks(); + + expect(onStateChange).not.toHaveBeenCalled(); + + window.removeEventListener('mousedown', onMouseDown); + }); + + test('closes panel and resets `activeItemId` if the target is outside Autocomplete', async () => { + const onStateChange = jest.fn(); + const { + getEnvironmentProps, + inputElement, + formElement, + } = createPlayground(createAutocomplete, { + onStateChange, + openOnFocus: true, + defaultActiveItemId: 1, + getSources() { + return [ + createSource({ + getItems: () => [{ label: '1' }], + }), + ]; + }, + }); + const panelElement = document.createElement('div'); + + const { onMouseDown } = getEnvironmentProps({ + inputElement, + formElement, + panelElement, + }); + window.addEventListener('mousedown', onMouseDown); + + // Click input (focuses it, which opens the panel) + userEvent.click(inputElement); + + await runAllMicroTasks(); + + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + isOpen: true, + }), + }) + ); + + onStateChange.mockClear(); + + // Dispatch MouseDown event on window (so, outside of Autocomplete) + const customEvent = new CustomEvent('mousedown', { bubbles: true }); + window.document.dispatchEvent(customEvent); + + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + isOpen: false, + activeItemId: null, + }), + }) + ); + + window.removeEventListener('mousedown', onMouseDown); + }); + + test('does not close panel nor reset `activeItemId` if the target is outside Autocomplete in debug mode', async () => { + const onStateChange = jest.fn(); + const { + getEnvironmentProps, + inputElement, + formElement, + } = createPlayground(createAutocomplete, { + onStateChange, + openOnFocus: true, + defaultActiveItemId: 1, + debug: true, + getSources() { + return [ + createSource({ + getItems: () => [{ label: '1' }], + }), + ]; + }, + }); + const panelElement = document.createElement('div'); + + const { onMouseDown } = getEnvironmentProps({ + inputElement, + formElement, + panelElement, + }); + window.addEventListener('mousedown', onMouseDown); + + // Click input (focuses it, which opens the panel) + userEvent.click(inputElement); + + await runAllMicroTasks(); + + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + isOpen: true, + }), + }) + ); + + onStateChange.mockClear(); + + // Dispatch MouseDown event on window (so, outside of Autocomplete) + const customEvent = new CustomEvent('mousedown', { bubbles: true }); + window.document.dispatchEvent(customEvent); + + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + isOpen: true, + activeItemId: 1, + }), + }) + ); + + window.removeEventListener('mousedown', onMouseDown); + }); + }); + describe('onTouchStart', () => { test('is a noop when panel is not open and status is idle', () => { const onStateChange = jest.fn(); @@ -107,7 +305,7 @@ describe('getEnvironmentProps', () => { window.removeEventListener('touchstart', onTouchStart); }); - test('closes panel if the target is outside Autocomplete', async () => { + test('closes panel and resets `activeItemId` if the target is outside Autocomplete', async () => { const onStateChange = jest.fn(); const { getEnvironmentProps, @@ -116,6 +314,7 @@ describe('getEnvironmentProps', () => { } = createPlayground(createAutocomplete, { onStateChange, openOnFocus: true, + defaultActiveItemId: 1, getSources() { return [ createSource({ @@ -156,6 +355,66 @@ describe('getEnvironmentProps', () => { expect.objectContaining({ state: expect.objectContaining({ isOpen: false, + activeItemId: null, + }), + }) + ); + + window.removeEventListener('touchstart', onTouchStart); + }); + + test('does not close panel nor reset `activeItemId` if the target is outside Autocomplete in debug mode', async () => { + const onStateChange = jest.fn(); + const { + getEnvironmentProps, + inputElement, + formElement, + } = createPlayground(createAutocomplete, { + onStateChange, + openOnFocus: true, + defaultActiveItemId: 1, + debug: true, + getSources() { + return [ + createSource({ + getItems: () => [{ label: '1' }], + }), + ]; + }, + }); + const panelElement = document.createElement('div'); + + const { onTouchStart } = getEnvironmentProps({ + inputElement, + formElement, + panelElement, + }); + window.addEventListener('touchstart', onTouchStart); + + // Focus input (opens the panel) + inputElement.focus(); + + await runAllMicroTasks(); + + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + isOpen: true, + }), + }) + ); + + onStateChange.mockClear(); + + // Dispatch TouchStart event on window (so, outside of Autocomplete) + const customEvent = new CustomEvent('touchstart', { bubbles: true }); + window.document.dispatchEvent(customEvent); + + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + isOpen: true, + activeItemId: 1, }), }) ); diff --git a/packages/autocomplete-core/src/__tests__/getInputProps.test.ts b/packages/autocomplete-core/src/__tests__/getInputProps.test.ts index e94827e79..eca8bb8d4 100644 --- a/packages/autocomplete-core/src/__tests__/getInputProps.test.ts +++ b/packages/autocomplete-core/src/__tests__/getInputProps.test.ts @@ -1034,6 +1034,7 @@ describe('getInputProps', () => { openOnFocus: true, initialState: { completion: 'a', + isOpen: true, collections: [ createCollection({ items: [{ label: '1' }, { label: '2' }], @@ -1042,7 +1043,6 @@ describe('getInputProps', () => { }, }); - inputElement.focus(); userEvent.type(inputElement, '{esc}'); expect(onStateChange).toHaveBeenLastCalledWith( @@ -1155,6 +1155,31 @@ describe('getInputProps', () => { expect(event.preventDefault).toHaveBeenCalledTimes(1); }); + test('closes the panel when no item was selected', async () => { + const onStateChange = jest.fn(); + const { inputElement } = createPlayground(createAutocomplete, { + onStateChange, + initialState: { + isOpen: true, + collections: [ + createCollection({ + items: [{ label: '1' }, { label: '2' }], + }), + ], + }, + }); + + userEvent.type(inputElement, '{enter}'); + + await runAllMicroTasks(); + + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ isOpen: false }), + }) + ); + }); + describe('Plain Enter', () => { test('calls onSelect with item URL', () => { const onSelect = jest.fn(); @@ -1664,6 +1689,180 @@ describe('getInputProps', () => { }); }); }); + + describe('Tab', () => { + test('closes the panel and resets `activeItemId`', async () => { + const onStateChange = jest.fn(); + const { inputElement } = createPlayground(createAutocomplete, { + onStateChange, + openOnFocus: true, + defaultActiveItemId: 1, + getSources() { + return [ + createSource({ + getItems: () => [{ label: '1' }], + }), + ]; + }, + }); + + userEvent.click(inputElement); + + await runAllMicroTasks(); + + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + isOpen: true, + activeItemId: 1, + }), + }) + ); + + userEvent.tab(); + + await runAllMicroTasks(); + + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + isOpen: false, + activeItemId: null, + }), + }) + ); + }); + + test('does not close closes the panel nor reset `activeItemId` in debug mode', async () => { + const onStateChange = jest.fn(); + const { inputElement } = createPlayground(createAutocomplete, { + onStateChange, + debug: true, + openOnFocus: true, + defaultActiveItemId: 1, + getSources() { + return [ + createSource({ + getItems: () => [{ label: '1' }], + }), + ]; + }, + }); + + userEvent.click(inputElement); + + await runAllMicroTasks(); + + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + isOpen: true, + activeItemId: 1, + }), + }) + ); + + userEvent.tab(); + + await runAllMicroTasks(); + + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + isOpen: true, + activeItemId: 1, + }), + }) + ); + }); + }); + + describe('Tab+Shift', () => { + test('closes the panel and resets `activeItemId`', async () => { + const onStateChange = jest.fn(); + const { inputElement } = createPlayground(createAutocomplete, { + onStateChange, + openOnFocus: true, + defaultActiveItemId: 1, + getSources() { + return [ + createSource({ + getItems: () => [{ label: '1' }], + }), + ]; + }, + }); + + userEvent.click(inputElement); + + await runAllMicroTasks(); + + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + isOpen: true, + activeItemId: 1, + }), + }) + ); + + userEvent.tab({ shift: true }); + + await runAllMicroTasks(); + + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + isOpen: false, + activeItemId: null, + }), + }) + ); + }); + + test('does not close closes the panel nor reset `activeItemId` in debug mode', async () => { + const onStateChange = jest.fn(); + const { inputElement } = createPlayground(createAutocomplete, { + onStateChange, + debug: true, + openOnFocus: true, + defaultActiveItemId: 1, + getSources() { + return [ + createSource({ + getItems: () => [{ label: '1' }], + }), + ]; + }, + }); + + userEvent.click(inputElement); + + await runAllMicroTasks(); + + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + isOpen: true, + activeItemId: 1, + }), + }) + ); + + userEvent.tab({ shift: true }); + + await runAllMicroTasks(); + + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + isOpen: true, + activeItemId: 1, + }), + }) + ); + }); + }); }); describe('onFocus', () => { @@ -1893,81 +2092,6 @@ describe('getInputProps', () => { }); }); - describe('onBlur', () => { - test('resets activeItemId and isOpen', async () => { - const onStateChange = jest.fn(); - const { inputElement } = createPlayground(createAutocomplete, { - onStateChange, - defaultActiveItemId: 1, - openOnFocus: true, - }); - - inputElement.focus(); - inputElement.blur(); - - await runAllMicroTasks(); - - expect(onStateChange).toHaveBeenLastCalledWith( - expect.objectContaining({ - state: expect.objectContaining({ - activeItemId: null, - isOpen: false, - }), - }) - ); - }); - - test('does not reset activeItemId and isOpen when debug is true', () => { - const onStateChange = jest.fn(); - const { inputElement } = createPlayground(createAutocomplete, { - onStateChange, - debug: true, - defaultActiveItemId: 1, - openOnFocus: true, - shouldPanelOpen: () => true, - }); - - inputElement.focus(); - inputElement.blur(); - - expect(onStateChange).toHaveBeenLastCalledWith( - expect.objectContaining({ - state: expect.objectContaining({ - activeItemId: 1, - isOpen: true, - }), - }) - ); - }); - - test('does not reset activeItemId and isOpen on touch devices', () => { - const environment = { - ...global, - ontouchstart: () => {}, - }; - const onStateChange = jest.fn(); - const { inputElement } = createPlayground(createAutocomplete, { - environment, - onStateChange, - defaultActiveItemId: 1, - openOnFocus: true, - shouldPanelOpen: () => true, - }); - - inputElement.focus(); - inputElement.blur(); - - expect(onStateChange).toHaveBeenLastCalledWith( - expect.objectContaining({ - state: expect.objectContaining({ - activeItemId: 1, - isOpen: true, - }), - }) - ); - }); - }); - describe('onClick', () => { test('is a noop when the input is not focused', () => { const onStateChange = jest.fn(); diff --git a/packages/autocomplete-core/src/getPropGetters.ts b/packages/autocomplete-core/src/getPropGetters.ts index f4793af18..c4921689a 100644 --- a/packages/autocomplete-core/src/getPropGetters.ts +++ b/packages/autocomplete-core/src/getPropGetters.ts @@ -1,3 +1,5 @@ +import { noop } from '@algolia/autocomplete-shared'; + import { onInput } from './onInput'; import { onKeyDown } from './onKeyDown'; import { @@ -31,46 +33,53 @@ export function getPropGetters< const getEnvironmentProps: GetEnvironmentProps = (providedProps) => { const { inputElement, formElement, panelElement, ...rest } = providedProps; - return { - // On touch devices, we do not rely on the native `blur` event of the - // input to close the panel, but rather on a custom `touchstart` event - // outside of the autocomplete elements. - // This ensures a working experience on mobile because we blur the input - // on touch devices when the user starts scrolling (`touchmove`). + function onMouseDownOrTouchStart(event: MouseEvent | TouchEvent) { + // The `onTouchStart`/`onMouseDown` events shouldn't trigger the `blur` + // handler when it's not an interaction with Autocomplete. + // We detect it with the following heuristics: + // - the panel is closed AND there are no pending requests + // (no interaction with the autocomplete, no future state updates) + // - OR the touched target is the input element (should open the panel) + const isAutocompleteInteraction = + store.getState().isOpen || !store.pendingRequests.isEmpty(); + + if (!isAutocompleteInteraction || event.target === inputElement) { + return; + } + // @TODO: support cases where there are multiple Autocomplete instances. // Right now, a second instance makes this computation return false. - onTouchStart(event) { - // The `onTouchStart` event shouldn't trigger the `blur` handler when - // it's not an interaction with Autocomplete. We detect it with the - // following heuristics: - // - the panel is closed AND there are no pending requests - // (no interaction with the autocomplete, no future state updates) - // - OR the touched target is the input element (should open the panel) - const isAutocompleteInteraction = - store.getState().isOpen || !store.pendingRequests.isEmpty(); - - if (!isAutocompleteInteraction || event.target === inputElement) { - return; + const isTargetWithinAutocomplete = [formElement, panelElement].some( + (contextNode) => { + return isOrContainsNode(contextNode, event.target as Node); } + ); + + if (isTargetWithinAutocomplete === false) { + store.dispatch('blur', null); - const isTargetWithinAutocomplete = [formElement, panelElement].some( - (contextNode) => { - return isOrContainsNode(contextNode, event.target as Node); - } - ); - - if (isTargetWithinAutocomplete === false) { - store.dispatch('blur', null); - - // If requests are still pending when the user closes the panel, they - // could reopen the panel once they resolve. - // We want to prevent any subsequent query from reopening the panel - // because it would result in an unsolicited UI behavior. - if (!props.debug) { - store.pendingRequests.cancelAll(); - } + // If requests are still pending when the user closes the panel, they + // could reopen the panel once they resolve. + // We want to prevent any subsequent query from reopening the panel + // because it would result in an unsolicited UI behavior. + if (!props.debug) { + store.pendingRequests.cancelAll(); } - }, + } + } + + return { + // We do not rely on the native `blur` event of the input to close the + // panel, but rather on a custom `touchstart`/`mousedown` event outside + // of the autocomplete elements. + // This ensures we don't mistakenly interpret interactions within the + // autocomplete (but outside of the input) as a signal to close the panel. + // For example, clicking reset button causes an input blur, but if + // `openOnFocus=true`, it shouldn't close the panel. + // On touch devices, scrolling results (`touchmove`) causes an input blur + // but shouldn't close the panel. + onTouchStart: onMouseDownOrTouchStart, + onMouseDown: onMouseDownOrTouchStart, // When scrolling on touch devices (mobiles, tablets, etc.), we want to // mimic the native platform behavior where the input is blurred to // hide the virtual keyboard. This gives more vertical space to @@ -158,7 +167,6 @@ export function getPropGetters< store.dispatch('focus', null); } - const isTouchDevice = 'ontouchstart' in props.environment; const { inputElement, maxLength = 512, ...rest } = providedProps || {}; const activeItem = getActiveItem(store.getState()); @@ -207,21 +215,10 @@ export function getPropGetters< }); }, onFocus, - onBlur: () => { - // We do rely on the `blur` event on touch devices. - // See explanation in `onTouchStart`. - if (!isTouchDevice) { - store.dispatch('blur', null); - - // If requests are still pending when the user closes the panel, they - // could reopen the panel once they resolve. - // We want to prevent any subsequent query from reopening the panel - // because it would result in an unsolicited UI behavior. - if (!props.debug) { - store.pendingRequests.cancelAll(); - } - } - }, + // We don't rely on the `blur` event. + // See explanation in `onTouchStart`/`onMouseDown`. + // @MAJOR See if we need to keep this handler. + onBlur: noop, onClick: (event) => { // When the panel is closed and you click on the input while // the input is focused, the `onFocus` event is not triggered diff --git a/packages/autocomplete-core/src/onKeyDown.ts b/packages/autocomplete-core/src/onKeyDown.ts index 65b9135c3..c0b50a34c 100644 --- a/packages/autocomplete-core/src/onKeyDown.ts +++ b/packages/autocomplete-core/src/onKeyDown.ts @@ -100,6 +100,14 @@ export function onKeyDown({ store.dispatch(event.key, null); + // Hitting the `Escape` key signals the end of a user interaction with the + // autocomplete. At this point, we should ignore any requests that are still + // pending and could reopen the panel once they resolve, because that would + // result in an unsolicited UI behavior. + store.pendingRequests.cancelAll(); + } else if (event.key === 'Tab') { + store.dispatch('blur', null); + // Hitting the `Escape` key signals the end of a user interaction with the // autocomplete. At this point, we should ignore any requests that are still // pending and could reopen the panel once they resolve, because that would @@ -114,6 +122,14 @@ export function onKeyDown({ .getState() .collections.every((collection) => collection.items.length === 0) ) { + // If requests are still pending when the panel closes, they could reopen + // the panel once they resolve. + // We want to prevent any subsequent query from reopening the panel + // because it would result in an unsolicited UI behavior. + if (!props.debug) { + store.pendingRequests.cancelAll(); + } + return; } diff --git a/packages/autocomplete-core/src/types/AutocompletePropGetters.ts b/packages/autocomplete-core/src/types/AutocompletePropGetters.ts index cef3c00c6..79e5d5a1b 100644 --- a/packages/autocomplete-core/src/types/AutocompletePropGetters.ts +++ b/packages/autocomplete-core/src/types/AutocompletePropGetters.ts @@ -25,6 +25,7 @@ export type GetEnvironmentProps = (props: { }) => { onTouchStart(event: TouchEvent): void; onTouchMove(event: TouchEvent): void; + onMouseDown(event: MouseEvent): void; }; export type GetRootProps = (props?: { diff --git a/packages/autocomplete-js/src/__tests__/autocomplete.test.ts b/packages/autocomplete-js/src/__tests__/autocomplete.test.ts index 4b01c21bc..39269e595 100644 --- a/packages/autocomplete-js/src/__tests__/autocomplete.test.ts +++ b/packages/autocomplete-js/src/__tests__/autocomplete.test.ts @@ -1,5 +1,6 @@ import * as autocompleteShared from '@algolia/autocomplete-shared'; import { fireEvent, waitFor } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; import { castToJestMock, @@ -17,6 +18,10 @@ jest.mock('@algolia/autocomplete-shared', () => { }; }); +beforeEach(() => { + document.body.innerHTML = ''; +}); + describe('autocomplete-js', () => { test('renders with default options', () => { const container = document.createElement('div'); @@ -562,4 +567,108 @@ describe('autocomplete-js', () => { expect(input).toHaveValue('a'); }); + + test('closes the panel and focuses the next focusable element on `Tab`', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + autocomplete<{ label: string }>({ + id: 'autocomplete', + container, + initialState: { + query: 'a', + isOpen: true, + }, + getSources() { + return [ + { + sourceId: 'testSource', + getItems() { + return [ + { label: 'Item 1' }, + { label: 'Item 2' }, + { label: 'Item 3' }, + ]; + }, + templates: { + item({ item }) { + return item.label; + }, + }, + }, + ]; + }, + }); + + // Wait for the panel to open + await waitFor(() => { + expect( + document.querySelector('.aa-Panel') + ).toBeInTheDocument(); + }); + + userEvent.click(document.querySelector('.aa-Input')!); + userEvent.tab(); + + await waitFor(() => { + expect( + document.querySelector('.aa-DetachedOverlay') + ).not.toBeInTheDocument(); + expect(document.body).not.toHaveClass('aa-Detached'); + expect(document.activeElement).toEqual( + document.querySelector('.aa-ClearButton') + ); + }); + }); + + test('closes the panel and focuses the next focusable element on `Shift+Tab`', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + autocomplete<{ label: string }>({ + id: 'autocomplete', + container, + initialState: { + query: 'a', + isOpen: true, + }, + getSources() { + return [ + { + sourceId: 'testSource', + getItems() { + return [ + { label: 'Item 1' }, + { label: 'Item 2' }, + { label: 'Item 3' }, + ]; + }, + templates: { + item({ item }) { + return item.label; + }, + }, + }, + ]; + }, + }); + + // Wait for the panel to open + await waitFor(() => { + expect( + document.querySelector('.aa-Panel') + ).toBeInTheDocument(); + }); + + userEvent.click(document.querySelector('.aa-Input')!); + userEvent.tab({ shift: true }); + + await waitFor(() => { + expect( + document.querySelector('.aa-DetachedOverlay') + ).not.toBeInTheDocument(); + expect(document.body).not.toHaveClass('aa-Detached'); + expect(document.activeElement).toEqual( + document.querySelector('.aa-SubmitButton') + ); + }); + }); }); diff --git a/packages/autocomplete-js/src/__tests__/detached.test.ts b/packages/autocomplete-js/src/__tests__/detached.test.ts index c0cd09780..4f92eacdc 100644 --- a/packages/autocomplete-js/src/__tests__/detached.test.ts +++ b/packages/autocomplete-js/src/__tests__/detached.test.ts @@ -1,8 +1,17 @@ -import { fireEvent, waitFor } from '@testing-library/dom'; +import { + fireEvent, + waitFor, + waitForElementToBeRemoved, +} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; import { createMatchMedia } from '../../../../test/utils'; import { autocomplete } from '../autocomplete'; +beforeEach(() => { + document.body.innerHTML = ''; +}); + describe('detached', () => { beforeAll(() => { Object.defineProperty(window, 'matchMedia', { @@ -144,4 +153,223 @@ describe('detached', () => { expect(document.body).not.toHaveClass('aa-Detached'); }); }); + + test('stays open after clear when `openOnFocus` is `true`', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + autocomplete<{ label: string }>({ + id: 'autocomplete', + detachedMediaQuery: '', + openOnFocus: true, + container, + initialState: { + query: 'a', + isOpen: true, + }, + getSources() { + return [ + { + sourceId: 'testSource', + getItems() { + return [ + { label: 'Item 1' }, + { label: 'Item 2' }, + { label: 'Item 3' }, + ]; + }, + templates: { + item({ item }) { + return item.label; + }, + }, + }, + ]; + }, + }); + + // Wait for the panel to open + await waitFor(() => { + expect( + document.querySelector('.aa-Panel') + ).toBeInTheDocument(); + }); + + // Clear the query + userEvent.click( + document.querySelector('.aa-ClearButton')! + ); + + // Ensures the overlay never disappears + await waitForElementToBeRemoved( + document.querySelector('.aa-DetachedOverlay') + ).catch(() => {}); + + await waitFor(() => { + expect(document.querySelector('.aa-DetachedOverlay')).toBeInTheDocument(); + expect(document.body).toHaveClass('aa-Detached'); + }); + }); + + test('closes after clear when `openOnFocus` is `false`', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + autocomplete<{ label: string }>({ + id: 'autocomplete', + detachedMediaQuery: '', + openOnFocus: false, + container, + initialState: { + query: 'a', + isOpen: true, + }, + getSources() { + return [ + { + sourceId: 'testSource', + getItems() { + return [ + { label: 'Item 1' }, + { label: 'Item 2' }, + { label: 'Item 3' }, + ]; + }, + templates: { + item({ item }) { + return item.label; + }, + }, + }, + ]; + }, + }); + + // Wait for the panel to open + await waitFor(() => { + expect( + document.querySelector('.aa-Panel') + ).toBeInTheDocument(); + }); + + // Clear the query + userEvent.click( + document.querySelector('.aa-ClearButton')! + ); + + await waitFor(() => { + expect( + document.querySelector('.aa-DetachedOverlay') + ).not.toBeInTheDocument(); + expect(document.body).not.toHaveClass('aa-Detached'); + }); + }); + + test('stays open and focuses the next focusable element on `Tab`', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + autocomplete<{ label: string }>({ + id: 'autocomplete', + detachedMediaQuery: '', + container, + initialState: { + query: 'a', + isOpen: true, + }, + getSources() { + return [ + { + sourceId: 'testSource', + getItems() { + return [ + { label: 'Item 1' }, + { label: 'Item 2' }, + { label: 'Item 3' }, + ]; + }, + templates: { + item({ item }) { + return item.label; + }, + }, + }, + ]; + }, + }); + + // Wait for the panel to open + await waitFor(() => { + expect( + document.querySelector('.aa-Panel') + ).toBeInTheDocument(); + }); + + userEvent.tab(); + + // Ensures the overlay never disappears + await waitForElementToBeRemoved( + document.querySelector('.aa-DetachedOverlay') + ).catch(() => {}); + + await waitFor(() => { + expect(document.querySelector('.aa-DetachedOverlay')).toBeInTheDocument(); + expect(document.body).toHaveClass('aa-Detached'); + expect(document.activeElement).toEqual( + document.querySelector('.aa-ClearButton') + ); + }); + }); + + test('stays open and focuses the previous focusable element on `Shift+Tab`', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + autocomplete<{ label: string }>({ + id: 'autocomplete', + detachedMediaQuery: '', + container, + initialState: { + query: 'a', + isOpen: true, + }, + getSources() { + return [ + { + sourceId: 'testSource', + getItems() { + return [ + { label: 'Item 1' }, + { label: 'Item 2' }, + { label: 'Item 3' }, + ]; + }, + templates: { + item({ item }) { + return item.label; + }, + }, + }, + ]; + }, + }); + + // Wait for the panel to open + await waitFor(() => { + expect( + document.querySelector('.aa-Panel') + ).toBeInTheDocument(); + }); + + userEvent.tab({ shift: true }); + + // Ensures the overlay never disappears + await waitForElementToBeRemoved( + document.querySelector('.aa-DetachedOverlay') + ).catch(() => {}); + + await waitFor(() => { + expect(document.querySelector('.aa-DetachedOverlay')).toBeInTheDocument(); + expect(document.body).toHaveClass('aa-Detached'); + expect(document.activeElement).toEqual( + document.querySelector('.aa-SubmitButton') + ); + }); + }); }); diff --git a/packages/autocomplete-js/src/__tests__/positioning.test.ts b/packages/autocomplete-js/src/__tests__/positioning.test.ts index a47bf59b8..bfb68a09d 100644 --- a/packages/autocomplete-js/src/__tests__/positioning.test.ts +++ b/packages/autocomplete-js/src/__tests__/positioning.test.ts @@ -191,7 +191,7 @@ describe('Panel positioning', () => { right: '1020px', }); - input.blur(); + userEvent.click(document.body); // Move the root vertically root.getBoundingClientRect = jest diff --git a/packages/autocomplete-js/src/createAutocompleteDom.ts b/packages/autocomplete-js/src/createAutocompleteDom.ts index 3ce303773..7803d71d4 100644 --- a/packages/autocomplete-js/src/createAutocompleteDom.ts +++ b/packages/autocomplete-js/src/createAutocompleteDom.ts @@ -101,12 +101,7 @@ export function createAutocompleteDom({ getInputProps: propGetters.getInputProps, getInputPropsCore: autocomplete.getInputProps, autocompleteScopeApi, - onDetachedEscape: isDetached - ? () => { - autocomplete.setIsOpen(false); - setIsModalOpen(false); - } - : undefined, + isDetached, }); const inputWrapperPrefix = createDomElement('div', { diff --git a/packages/autocomplete-js/src/elements/Input.ts b/packages/autocomplete-js/src/elements/Input.ts index 8d06b9259..b687231a0 100644 --- a/packages/autocomplete-js/src/elements/Input.ts +++ b/packages/autocomplete-js/src/elements/Input.ts @@ -14,7 +14,7 @@ type InputProps = { environment: AutocompleteEnvironment; getInputProps: AutocompletePropGetters['getInputProps']; getInputPropsCore: AutocompleteCoreApi['getInputProps']; - onDetachedEscape?(): void; + isDetached: boolean; state: AutocompleteState; }; @@ -24,7 +24,7 @@ export const Input: AutocompleteElement = ({ classNames, getInputProps, getInputPropsCore, - onDetachedEscape, + isDetached, state, ...props }) => { @@ -40,9 +40,8 @@ export const Input: AutocompleteElement = ({ setProperties(element, { ...inputProps, onKeyDown(event: KeyboardEvent) { - if (onDetachedEscape && event.key === 'Escape') { - event.preventDefault(); - onDetachedEscape(); + // In detached mode we don't want to close the panel when hittin `Tab`. + if (isDetached && event.key === 'Tab') { return; }