diff --git a/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx b/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx index 37e48bb65..392128144 100644 --- a/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx +++ b/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx @@ -23,7 +23,7 @@ import React, { useCallback, useRef, useState } from "react"; import type { ListRenderItem, TextInput as RNTextInput } from "react-native"; import { FlatList, Keyboard } from "react-native"; import useCurrentUser from "sharedHooks/useCurrentUser"; -import useDebouncedValue from "sharedHooks/useDebouncedValue"; +import useSearchField from "sharedHooks/useSearchField"; import useTranslation from "sharedHooks/useTranslation"; import type { UniversalSearchResultItem } from "sharedHooks/useUniversalSearch"; import useUniversalSearch from "sharedHooks/useUniversalSearch"; @@ -60,52 +60,33 @@ const UniversalSearch = ( ) => { const commonNameIsPrimary = currentUser?.prefers_common_names !== false && currentUser?.prefers_scientific_name_first !== true; - const [subjectText, setSubjectText] = useState( "" ); - const [locationText, setLocationText] = useState( "" ); - const [filledFromSelection, setFilledFromSelection] = useState( false ); - // The debounced value that actually drives the autocomplete query. Cleared on - // selection so a chosen suggestion doesn't re-trigger a result list. const { - debouncedValue: debouncedQuery, - debounce: debounceQuery, - setImmediately: setQueryImmediately, - } = useDebouncedValue( "" ); + text: subjectText, + debouncedQuery: subjectQuery, + hasQuery: subjectHasQuery, + onChangeText: onChangeSubjectText, + handleFocus: focusSubjectField, + commit: commitSubject, + clear: clearSubject, + } = useSearchField( ); + const [locationText, setLocationText] = useState( "" ); const locationInputRef = useRef( null ); - const { results, isLoading, refetch } = useUniversalSearch( debouncedQuery ); + const { results, isLoading, refetch } = useUniversalSearch( subjectQuery ); const bothFilled = subjectText.length > 0 && locationText.length > 0; - const hasQuery = debouncedQuery.trim( ).length > 0; - - const handleSubjectTextChange = useCallback( ( text: string ) => { - setSubjectText( text ); - setFilledFromSelection( false ); - debounceQuery( text ); - }, [debounceQuery] ); - - const handleSubjectFocus = useCallback( ( ) => { - if ( !filledFromSelection ) { return; } - setSubjectText( "" ); - setFilledFromSelection( false ); - setQueryImmediately( "" ); - }, [filledFromSelection, setQueryImmediately] ); - const handleSelect = useCallback( ( subject: ExploreV2Subject ) => { - setSubjectText( subjectToText( subject, commonNameIsPrimary ) ); - setFilledFromSelection( true ); - setQueryImmediately( "" ); + commitSubject( subjectToText( subject, commonNameIsPrimary ) ); dispatch( { type: EXPLORE_V2_ACTION.SET_SUBJECT, subject } ); locationInputRef.current?.focus( ); - }, [commonNameIsPrimary, dispatch, setQueryImmediately] ); + }, [commitSubject, commonNameIsPrimary, dispatch] ); const handleReset = useCallback( ( ) => { - setSubjectText( "" ); + clearSubject( ); setLocationText( "" ); - setFilledFromSelection( false ); - setQueryImmediately( "" ); - }, [setQueryImmediately] ); + }, [clearSubject] ); const handleSearch = useCallback( ( ) => { // TODO MOB-1338 follow-up: run the search (default to all organisms / @@ -139,8 +120,8 @@ const UniversalSearch = ( ) => { autoFocus className="flex-1 ml-2 text-md font-Lato-Regular" numberOfLines={1} - onChangeText={handleSubjectTextChange} - onFocus={handleSubjectFocus} + onChangeText={onChangeSubjectText} + onFocus={focusSubjectField} placeholder={t( "Search-for-species-user-or-project" )} placeholderTextColor={colors.mediumGray} testID="UniversalSearch.subjectInput" @@ -188,7 +169,7 @@ const UniversalSearch = ( ) => { {/* Only surface results for an active query */} { ListEmptyComponent={( )} diff --git a/src/sharedHooks/useSearchField.ts b/src/sharedHooks/useSearchField.ts new file mode 100644 index 000000000..024e4fba2 --- /dev/null +++ b/src/sharedHooks/useSearchField.ts @@ -0,0 +1,71 @@ +import { useCallback, useState } from "react"; +import useDebouncedValue from "sharedHooks/useDebouncedValue"; + +export interface SearchField { + // The controlled input value. + text: string; + // The debounced value that drives the autocomplete query. Cleared on selection + // so a chosen suggestion doesn't re-trigger a result list. + debouncedQuery: string; + // Whether there's a non-empty query to surface results for. + hasQuery: boolean; + // Wire to the input's onChangeText. + onChangeText: ( text: string ) => void; + // Wire to the input's onFocus: clears a previously-committed value so the user + // gets a fresh search when they tap back in. + handleFocus: ( ) => void; + // Fill the field from a chosen suggestion (without re-triggering a query). + commit: ( text: string ) => void; + // Clear the field entirely (e.g. on reset). + clear: ( ) => void; +} + +// The shared state machine behind a debounced autocomplete search input: text + +// debounced query + "fill on select, clear on re-focus". The Universal Search +// subject field is an instance of this. +const useSearchField = ( ): SearchField => { + const [text, setText] = useState( "" ); + const [filledFromSelection, setFilledFromSelection] = useState( false ); + const { + debouncedValue: debouncedQuery, + debounce, + setImmediately, + } = useDebouncedValue( "" ); + + const onChangeText = useCallback( ( next: string ) => { + setText( next ); + setFilledFromSelection( false ); + debounce( next ); + }, [debounce] ); + + const handleFocus = useCallback( ( ) => { + if ( !filledFromSelection ) { return; } + setText( "" ); + setFilledFromSelection( false ); + setImmediately( "" ); + }, [filledFromSelection, setImmediately] ); + + const commit = useCallback( ( next: string ) => { + setText( next ); + setFilledFromSelection( true ); + setImmediately( "" ); + }, [setImmediately] ); + + const clear = useCallback( ( ) => { + setText( "" ); + setFilledFromSelection( false ); + setImmediately( "" ); + }, [setImmediately] ); + + return { + text, + debouncedQuery, + hasQuery: debouncedQuery.trim( ).length > 0, + onChangeText, + handleFocus, + commit, + clear, + }; +}; + +export default useSearchField;