Skip to content

Commit

Permalink
fix(core): open closed panel on ArrowDown and ArrowUp (#599)
Browse files Browse the repository at this point in the history
  • Loading branch information
shortcuts authored Jul 6, 2021
1 parent e019b4d commit 37ebefe
Show file tree
Hide file tree
Showing 5 changed files with 287 additions and 30 deletions.
2 changes: 1 addition & 1 deletion bundlesize.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
217 changes: 217 additions & 0 deletions packages/autocomplete-core/src/__tests__/getInputProps.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { waitFor } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';

import {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -860,6 +1076,7 @@ describe('getInputProps', () => {
state: expect.objectContaining({
query: '',
status: 'idle',
activeItemId: null,
collections: [],
}),
})
Expand Down
80 changes: 58 additions & 22 deletions packages/autocomplete-core/src/onKeyDown.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { onInput } from './onInput';
import {
ActionType,
AutocompleteScopeApi,
AutocompleteStore,
BaseItem,
Expand All @@ -22,39 +23,74 @@ export function onKeyDown<TItem extends BaseItem>({
...setters
}: OnKeyDownOptions<TItem>): 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"]`
Expand Down
16 changes: 10 additions & 6 deletions packages/autocomplete-core/src/stateReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -90,13 +92,15 @@ export const stateReducer: Reducer = (state, action) => {
if (state.isOpen) {
return {
...state,
activeItemId: null,
isOpen: false,
completion: null,
};
}

return {
...state,
activeItemId: null,
query: '',
status: 'idle',
collections: [],
Expand Down
2 changes: 1 addition & 1 deletion packages/autocomplete-core/src/types/AutocompleteStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ type Action<TItem extends BaseItem, TPayload> = {
payload: TPayload;
};

type ActionType =
export type ActionType =
| 'setActiveItemId'
| 'setQuery'
| 'setCollections'
Expand Down

0 comments on commit 37ebefe

Please sign in to comment.