diff --git a/packages/autocomplete-core/src/index.ts b/packages/autocomplete-core/src/index.ts index cddccbe5a..b3d1b1678 100644 --- a/packages/autocomplete-core/src/index.ts +++ b/packages/autocomplete-core/src/index.ts @@ -22,6 +22,7 @@ function createAutocomplete( setContext, } = getAutocompleteSetters({ store, props }); const { + getEnvironmentProps, getRootProps, getFormProps, getInputProps, @@ -46,6 +47,7 @@ function createAutocomplete( setIsOpen, setStatus, setContext, + getEnvironmentProps, getRootProps, getFormProps, getInputProps, diff --git a/packages/autocomplete-core/src/propGetters.ts b/packages/autocomplete-core/src/propGetters.ts index 0a4e0a9af..932547778 100644 --- a/packages/autocomplete-core/src/propGetters.ts +++ b/packages/autocomplete-core/src/propGetters.ts @@ -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, @@ -30,6 +31,64 @@ export function getPropGetters({ setStatus, setContext, }: GetPropGettersOptions) { + 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', @@ -119,7 +178,7 @@ export function getPropGetters({ ); } - const { inputElement, ...rest } = providedProps; + const { inputElement, dropdownElement, ...rest } = providedProps; return { 'aria-autocomplete': props.showCompletion ? 'both' : 'list', @@ -167,16 +226,20 @@ export function getPropGetters({ }, 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 @@ -327,6 +390,7 @@ export function getPropGetters({ }; return { + getEnvironmentProps, getRootProps, getFormProps, getInputProps, diff --git a/packages/autocomplete-core/src/stateReducer.ts b/packages/autocomplete-core/src/stateReducer.ts index 7bfe52b0d..53d78e355 100644 --- a/packages/autocomplete-core/src/stateReducer.ts +++ b/packages/autocomplete-core/src/stateReducer.ts @@ -16,10 +16,10 @@ type ActionType = | 'submit' | 'reset' | 'focus' + | 'blur' | 'mousemove' | 'mouseleave' - | 'click' - | 'blur'; + | 'click'; interface Action { type: ActionType; @@ -148,11 +148,9 @@ export const stateReducer = ( } 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, }; } diff --git a/packages/autocomplete-core/src/types/getters.ts b/packages/autocomplete-core/src/types/getters.ts index 08a5d5fd1..bfb7beac0 100644 --- a/packages/autocomplete-core/src/types/getters.ts +++ b/packages/autocomplete-core/src/types/getters.ts @@ -1,6 +1,7 @@ import { AutocompleteSource } from './api'; export interface AutocompleteAccessibilityGetters { + getEnvironmentProps: GetEnvironmentProps; getRootProps: GetRootProps; getFormProps: GetFormProps; getInputProps: GetInputProps; @@ -9,6 +10,16 @@ export interface AutocompleteAccessibilityGetters { 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; }) => { @@ -30,6 +41,7 @@ export type GetFormProps = (props: { export type GetInputProps = (props: { [key: string]: unknown; inputElement: HTMLInputElement; + dropdownElement: HTMLElement; }) => { id: string; value: string; diff --git a/packages/autocomplete-core/src/utils.ts b/packages/autocomplete-core/src/utils.ts index 2a4710af3..6334f77d0 100644 --- a/packages/autocomplete-core/src/utils.ts +++ b/packages/autocomplete-core/src/utils.ts @@ -191,3 +191,7 @@ export function getHighlightedItem({ source, }; } + +export function isOrContainsNode(parent: Node, child: Node) { + return parent === child || (parent.contains && parent.contains(child)); +} diff --git a/packages/autocomplete-react/src/Autocomplete.tsx b/packages/autocomplete-react/src/Autocomplete.tsx index e20a8d50c..30d8377b1 100644 --- a/packages/autocomplete-react/src/Autocomplete.tsx +++ b/packages/autocomplete-react/src/Autocomplete.tsx @@ -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 { @@ -71,6 +71,26 @@ export function Autocomplete( const [state, autocomplete] = useAutocomplete(props); useDropdown(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 (
( ; searchBoxRef: Ref; + dropdownRef: Ref; } export function SearchBox(props: SearchBoxProps) { @@ -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', })} diff --git a/stories/react.stories.tsx b/stories/react.stories.tsx index 1953db2f4..7840f92e9 100644 --- a/stories/react.stories.tsx +++ b/stories/react.stories.tsx @@ -133,6 +133,43 @@ storiesOf('React', module) container ); + return container; + }) + ) + .add( + 'Long list of items', + withPlayground(({ container, dropdownContainer }) => { + render( + { + 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; }) );