diff --git a/bundlesize.config.json b/bundlesize.config.json index 799fd205a..d3ea509d5 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -6,7 +6,7 @@ }, { "path": "packages/autocomplete-js/dist/umd/index.production.js", - "maxSize": "15.5 kB" + "maxSize": "15.75 kB" }, { "path": "packages/autocomplete-preset-algolia/dist/umd/index.production.js", diff --git a/packages/autocomplete-core/src/__tests__/getInputProps.test.ts b/packages/autocomplete-core/src/__tests__/getInputProps.test.ts index a1f5a44e9..0b27d5235 100644 --- a/packages/autocomplete-core/src/__tests__/getInputProps.test.ts +++ b/packages/autocomplete-core/src/__tests__/getInputProps.test.ts @@ -1,3 +1,4 @@ +import { waitFor } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; import { @@ -785,6 +786,221 @@ describe('getInputProps', () => { userEvent.type(inputElement, '{arrowup}'); expect(onActive).toHaveBeenCalledTimes(1); }); + + test('ArrowDown opens the panel when closed with openOnFocus and selects defaultActiveItemId', async () => { + const onStateChange = jest.fn(); + const { inputElement } = createPlayground(createAutocomplete, { + onStateChange, + openOnFocus: true, + getSources() { + return [ + createSource({ + getItems() { + return [{ label: '1' }]; + }, + }), + ]; + }, + }); + + inputElement.focus(); + await runAllMicroTasks(); + + userEvent.type(inputElement, '{esc}{arrowdown}'); + await runAllMicroTasks(); + + await waitFor(() => { + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + isOpen: true, + activeItemId: null, + }), + }) + ); + }); + }); + + test('ArrowDown opens the panel when closed with a query and selects defaultActiveItemId', async () => { + const onStateChange = jest.fn(); + const { inputElement } = createPlayground(createAutocomplete, { + onStateChange, + initialState: { + query: 'a', + }, + getSources() { + return [ + createSource({ + getItems() { + return [{ label: '1' }]; + }, + }), + ]; + }, + }); + + inputElement.focus(); + await runAllMicroTasks(); + + userEvent.type(inputElement, '{esc}{arrowdown}'); + await runAllMicroTasks(); + + await waitFor(() => { + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + isOpen: true, + activeItemId: null, + }), + }) + ); + }); + }); + + test('ArrowDown opens the panel when closed with openOnFocus and selects defaultActiveItemId with scrollIntoView', async () => { + const onStateChange = jest.fn(); + const { inputElement, item } = setupTestWithItem({ + onStateChange, + defaultActiveItemId: 0, + getSources() { + return [ + createSource({ + getItems() { + return [{ label: '1' }]; + }, + }), + ]; + }, + }); + item.scrollIntoView = jest.fn(); + + inputElement.focus(); + await runAllMicroTasks(); + + userEvent.type(inputElement, '{esc}{arrowdown}'); + await runAllMicroTasks(); + + await waitFor(() => { + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + isOpen: true, + activeItemId: 0, + }), + }) + ); + + expect(item.scrollIntoView).toHaveBeenCalledTimes(1); + expect(item.scrollIntoView).toHaveBeenCalledWith(false); + }); + }); + + test('ArrowUp opens the panel when closed with openOnFocus and selects the last item', async () => { + const onStateChange = jest.fn(); + const { inputElement } = createPlayground(createAutocomplete, { + onStateChange, + openOnFocus: true, + getSources() { + return [ + createSource({ + getItems() { + return [{ label: '1' }]; + }, + }), + ]; + }, + }); + + inputElement.focus(); + await runAllMicroTasks(); + + userEvent.type(inputElement, '{esc}{arrowup}'); + await runAllMicroTasks(); + + await waitFor(() => { + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + isOpen: true, + activeItemId: 0, + }), + }) + ); + }); + }); + + test('ArrowUp opens the panel when closed with a query and selects the last item', async () => { + const onStateChange = jest.fn(); + const { inputElement } = createPlayground(createAutocomplete, { + onStateChange, + initialState: { + query: 'a', + }, + getSources() { + return [ + createSource({ + getItems() { + return [{ label: '1' }]; + }, + }), + ]; + }, + }); + + inputElement.focus(); + await runAllMicroTasks(); + + userEvent.type(inputElement, '{esc}{arrowup}'); + await runAllMicroTasks(); + + await waitFor(() => { + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + isOpen: true, + activeItemId: 0, + }), + }) + ); + }); + }); + + test('ArrowUp opens the panel when closed with openOnFocus and selects the last item with scrollIntoView', async () => { + const onStateChange = jest.fn(); + const { inputElement, item } = setupTestWithItem({ + onStateChange, + getSources() { + return [ + createSource({ + getItems() { + return [{ label: '1' }]; + }, + }), + ]; + }, + }); + item.scrollIntoView = jest.fn(); + + inputElement.focus(); + await runAllMicroTasks(); + + userEvent.type(inputElement, '{esc}{arrowup}'); + await runAllMicroTasks(); + + await waitFor(() => { + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + isOpen: true, + activeItemId: 0, + }), + }) + ); + + expect(item.scrollIntoView).toHaveBeenCalledTimes(1); + expect(item.scrollIntoView).toHaveBeenCalledWith(false); + }); + }); }); describe('Escape', () => { @@ -860,6 +1076,7 @@ describe('getInputProps', () => { state: expect.objectContaining({ query: '', status: 'idle', + activeItemId: null, collections: [], }), }) diff --git a/packages/autocomplete-core/src/onKeyDown.ts b/packages/autocomplete-core/src/onKeyDown.ts index 333b98860..ee37a463d 100644 --- a/packages/autocomplete-core/src/onKeyDown.ts +++ b/packages/autocomplete-core/src/onKeyDown.ts @@ -1,5 +1,6 @@ import { onInput } from './onInput'; import { + ActionType, AutocompleteScopeApi, AutocompleteStore, BaseItem, @@ -22,39 +23,74 @@ export function onKeyDown({ ...setters }: OnKeyDownOptions): void { if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { - // Default browser behavior changes the caret placement on ArrowUp and - // Arrow down. - event.preventDefault(); + // eslint-disable-next-line no-inner-declarations + function triggerScrollIntoView() { + const nodeItem = props.environment.document.getElementById( + `${props.id}-item-${store.getState().activeItemId}` + ); - store.dispatch(event.key, null); + if (nodeItem) { + if ((nodeItem as any).scrollIntoViewIfNeeded) { + (nodeItem as any).scrollIntoViewIfNeeded(false); + } else { + nodeItem.scrollIntoView(false); + } + } + } + + // eslint-disable-next-line no-inner-declarations + function triggerOnActive() { + const highlightedItem = getActiveItem(store.getState()); - const nodeItem = props.environment.document.getElementById( - `${props.id}-item-${store.getState().activeItemId}` - ); + if (store.getState().activeItemId !== null && highlightedItem) { + const { item, itemInputValue, itemUrl, source } = highlightedItem; - if (nodeItem) { - if ((nodeItem as any).scrollIntoViewIfNeeded) { - (nodeItem as any).scrollIntoViewIfNeeded(false); - } else { - nodeItem.scrollIntoView(false); + source.onActive({ + event, + item, + itemInputValue, + itemUrl, + refresh, + source, + state: store.getState(), + ...setters, + }); } } - const highlightedItem = getActiveItem(store.getState()); - - if (store.getState().activeItemId !== null && highlightedItem) { - const { item, itemInputValue, itemUrl, source } = highlightedItem; + // Default browser behavior changes the caret placement on ArrowUp and + // ArrowDown. + event.preventDefault(); - source.onActive({ + // When re-opening the panel, we need to split the logic to keep the actions + // synchronized as `onInput` returns a promise. + if ( + store.getState().isOpen === false && + (props.openOnFocus || Boolean(store.getState().query)) + ) { + onInput({ event, - item, - itemInputValue, - itemUrl, + props, + query: store.getState().query, refresh, - source, - state: store.getState(), + store, ...setters, + }).then(() => { + store.dispatch(event.key as ActionType, { + nextActiveItemId: props.defaultActiveItemId, + }); + + triggerOnActive(); + // Since we rely on the DOM, we need to wait for all the micro tasks to + // finish (which include re-opening the panel) to make sure all the + // elements are available. + setTimeout(triggerScrollIntoView, 0); }); + } else { + store.dispatch(event.key, {}); + + triggerOnActive(); + triggerScrollIntoView(); } } else if (event.key === 'Escape') { // This prevents the default browser behavior on `input[type="search"]` diff --git a/packages/autocomplete-core/src/stateReducer.ts b/packages/autocomplete-core/src/stateReducer.ts index debe7fc08..ced4cfea2 100644 --- a/packages/autocomplete-core/src/stateReducer.ts +++ b/packages/autocomplete-core/src/stateReducer.ts @@ -55,12 +55,14 @@ export const stateReducer: Reducer = (state, action) => { case 'ArrowDown': { const nextState = { ...state, - activeItemId: getNextActiveItemId( - 1, - state.activeItemId, - getItemsCount(state), - action.props.defaultActiveItemId - ), + activeItemId: action.payload.hasOwnProperty('nextActiveItemId') + ? action.payload.nextActiveItemId + : getNextActiveItemId( + 1, + state.activeItemId, + getItemsCount(state), + action.props.defaultActiveItemId + ), }; return { @@ -90,6 +92,7 @@ export const stateReducer: Reducer = (state, action) => { if (state.isOpen) { return { ...state, + activeItemId: null, isOpen: false, completion: null, }; @@ -97,6 +100,7 @@ export const stateReducer: Reducer = (state, action) => { return { ...state, + activeItemId: null, query: '', status: 'idle', collections: [], diff --git a/packages/autocomplete-core/src/types/AutocompleteStore.ts b/packages/autocomplete-core/src/types/AutocompleteStore.ts index 506ea5595..7334a4a35 100644 --- a/packages/autocomplete-core/src/types/AutocompleteStore.ts +++ b/packages/autocomplete-core/src/types/AutocompleteStore.ts @@ -18,7 +18,7 @@ type Action = { payload: TPayload; }; -type ActionType = +export type ActionType = | 'setActiveItemId' | 'setQuery' | 'setCollections'