Skip to content
This repository has been archived by the owner on Jun 11, 2021. It is now read-only.

feat(core): introduce getEnvironmentProps for mobile experience #27

Merged
merged 6 commits into from
Feb 20, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/autocomplete-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function createAutocomplete<TItem extends {}>(
setContext,
} = getAutocompleteSetters({ store, props });
const {
getEnvironmentProps,
getRootProps,
getFormProps,
getInputProps,
Expand All @@ -46,6 +47,7 @@ function createAutocomplete<TItem extends {}>(
setIsOpen,
setStatus,
setContext,
getEnvironmentProps,
getRootProps,
getFormProps,
getInputProps,
Expand Down
88 changes: 76 additions & 12 deletions packages/autocomplete-core/src/propGetters.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { stateReducer } from './stateReducer';
import { onInput } from './onInput';
import { onKeyDown } from './onKeyDown';
import { isSpecialClick, getHighlightedItem } from './utils';
import { isSpecialClick, getHighlightedItem, isOrContainsNode } from './utils';

import {
GetEnvironmentProps,
GetRootProps,
GetFormProps,
GetInputProps,
Expand All @@ -30,6 +31,64 @@ export function getPropGetters<TItem>({
setStatus,
setContext,
}: GetPropGettersOptions<TItem>) {
const isTouchDevice = 'ontouchstart' in props.environment;

const getEnvironmentProps: GetEnvironmentProps = getterProps => {
return {
// On touch devices, we do not rely on the native `blur` event of the
// input to close the dropdown, 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`).
onTouchStart(event) {
if (store.getState().isOpen === false) {
return;
}

const isTargetWithinAutocomplete = [
getterProps.searchBoxElement,
getterProps.dropdownElement,
].some(contextNode => {
return (
contextNode &&
(isOrContainsNode(contextNode, event.target as Node) ||
isOrContainsNode(
contextNode,
props.environment.document.activeElement!
))
);
});

if (isTargetWithinAutocomplete === false) {
store.setState(
stateReducer(
store.getState(),
{
type: 'blur',
value: null,
},
props
)
);
}
},
// 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
// discover all the suggestions showing up in the dropdown.
onTouchMove() {
if (
store.getState().isOpen === false ||
getterProps.inputElement !== props.environment.document.activeElement
) {
return;
}

getterProps.inputElement.blur();
},
};
};

const getRootProps: GetRootProps = rest => {
return {
role: 'combobox',
Expand Down Expand Up @@ -119,7 +178,7 @@ export function getPropGetters<TItem>({
);
}

const { inputElement, ...rest } = providedProps;
const { inputElement, dropdownElement, ...rest } = providedProps;

return {
'aria-autocomplete': props.showCompletion ? 'both' : 'list',
Expand Down Expand Up @@ -167,16 +226,20 @@ export function getPropGetters<TItem>({
},
onFocus,
onBlur: () => {
store.setState(
stateReducer(
store.getState(),
{
type: 'blur',
value: null,
},
props
)
);
// We do rely on the `blur` event on touch devices.
// See explanation in `onTouchStart`.
if (!isTouchDevice) {
store.setState(
stateReducer(
store.getState(),
{
type: 'blur',
value: null,
},
props
)
);
}
},
onClick: () => {
// When the dropdown is closed and you click on the input while
Expand Down Expand Up @@ -327,6 +390,7 @@ export function getPropGetters<TItem>({
};

return {
getEnvironmentProps,
getRootProps,
getFormProps,
getInputProps,
Expand Down
8 changes: 3 additions & 5 deletions packages/autocomplete-core/src/stateReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ type ActionType =
| 'submit'
| 'reset'
| 'focus'
| 'blur'
| 'mousemove'
| 'mouseleave'
| 'click'
| 'blur';
| 'click';

interface Action {
type: ActionType;
Expand Down Expand Up @@ -148,11 +148,9 @@ export const stateReducer = <TItem>(
}

case 'blur': {
// In development mode, we prefer keeping the dropdown open on blur
// to use the browser dev tools.
return {
...state,
isOpen: __DEV__ ? state.isOpen : false,
isOpen: false,
highlightedIndex: null,
};
}
Expand Down
12 changes: 12 additions & 0 deletions packages/autocomplete-core/src/types/getters.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AutocompleteSource } from './api';

export interface AutocompleteAccessibilityGetters<TItem> {
getEnvironmentProps: GetEnvironmentProps;
getRootProps: GetRootProps;
getFormProps: GetFormProps;
getInputProps: GetInputProps;
Expand All @@ -9,6 +10,16 @@ export interface AutocompleteAccessibilityGetters<TItem> {
getMenuProps: GetMenuProps;
}

export type GetEnvironmentProps = (props: {
[key: string]: unknown;
searchBoxElement: HTMLElement;
dropdownElement: HTMLElement;
inputElement: HTMLInputElement;
}) => {
onTouchStart(event: TouchEvent): void;
onTouchMove(event: TouchEvent): void;
};

export type GetRootProps = (props?: {
[key: string]: unknown;
}) => {
Expand All @@ -30,6 +41,7 @@ export type GetFormProps = (props: {
export type GetInputProps = (props: {
[key: string]: unknown;
inputElement: HTMLInputElement;
dropdownElement: HTMLElement;
}) => {
id: string;
value: string;
Expand Down
4 changes: 4 additions & 0 deletions packages/autocomplete-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,7 @@ export function getHighlightedItem<TItem>({
source,
};
}

export function isOrContainsNode(parent: Node, child: Node) {
return parent === child || (parent.contains && parent.contains(child));
}
23 changes: 22 additions & 1 deletion packages/autocomplete-react/src/Autocomplete.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** @jsx h */

import { h } from 'preact';
import { useRef } from 'preact/hooks';
import { useRef, useEffect } from 'preact/hooks';
import { createPortal } from 'preact/compat';

import {
Expand Down Expand Up @@ -71,6 +71,26 @@ export function Autocomplete<TItem extends {}>(
const [state, autocomplete] = useAutocomplete<TItem>(props);
useDropdown<TItem>(rendererProps, state, searchBoxRef, dropdownRef);

useEffect(() => {
if (!(searchBoxRef.current && dropdownRef.current && inputRef.current)) {
return undefined;
}

const { onTouchStart, onTouchMove } = autocomplete.getEnvironmentProps({
searchBoxElement: searchBoxRef.current,
dropdownElement: dropdownRef.current,
inputElement: inputRef.current,
});

props.environment.addEventListener('touchstart', onTouchStart);
props.environment.addEventListener('touchmove', onTouchMove);

return () => {
props.environment.removeEventListener('touchstart', onTouchStart);
props.environment.removeEventListener('touchmove', onTouchMove);
};
}, [autocomplete, searchBoxRef, dropdownRef, inputRef, props.environment]);

return (
<div
className={[
Expand All @@ -85,6 +105,7 @@ export function Autocomplete<TItem extends {}>(
<SearchBox
searchBoxRef={searchBoxRef}
inputRef={inputRef}
dropdownRef={dropdownRef}
placeholder={props.placeholder}
query={state.query}
isOpen={state.isOpen}
Expand Down
2 changes: 2 additions & 0 deletions packages/autocomplete-react/src/SearchBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface SearchBoxProps {
getLabelProps: GetLabelProps;
inputRef: Ref<HTMLInputElement>;
searchBoxRef: Ref<HTMLFormElement>;
dropdownRef: Ref<HTMLElement>;
}

export function SearchBox(props: SearchBoxProps) {
Expand Down Expand Up @@ -87,6 +88,7 @@ export function SearchBox(props: SearchBoxProps) {
{...props.getInputProps({
ref: props.inputRef,
inputElement: (props.inputRef as any).current,
dropdownElement: (props.dropdownRef as any).current,
type: 'search',
maxLength: '512',
})}
Expand Down
37 changes: 37 additions & 0 deletions stories/react.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,43 @@ storiesOf('React', module)
container
);

return container;
})
)
.add(
'Long list of items',
withPlayground(({ container, dropdownContainer }) => {
render(
<Autocomplete
placeholder="Search items…"
dropdownContainer={dropdownContainer}
getSources={() => {
return [
{
getInputValue({ suggestion }) {
return suggestion.query;
},
getSuggestions({ query }) {
return getAlgoliaHits({
searchClient,
queries: [
{
indexName: 'instant_search_demo_query_suggestions',
query,
params: {
hitsPerPage: 40,
},
},
],
});
},
},
];
}}
/>,
container
);

return container;
})
);