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

Commit

Permalink
feat(core): introduce getEnvironmentProps for mobile experience (#27)
Browse files Browse the repository at this point in the history
  • Loading branch information
francoischalifour authored Feb 20, 2020
1 parent 337b956 commit f9d7eed
Show file tree
Hide file tree
Showing 8 changed files with 158 additions and 18 deletions.
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;
})
);

0 comments on commit f9d7eed

Please sign in to comment.