From 298c83bcbafeca97afa58ce22a00a7450291c13f Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:34:17 -0700 Subject: [PATCH] Update search input with `useAutocomplete` (#11023) --- src/components/Autocomplete/Autocomplete.tsx | 29 ++--- src/components/Autocomplete/types.ts | 1 + .../Autocomplete/useAutocomplete/index.ts | 1 + src/screens/Search/Shell.tsx | 73 ++++++++--- .../Search/components/AutocompleteResults.tsx | 105 +++++++-------- .../SearchAutocompleteInput/index.native.tsx | 17 +++ .../SearchAutocompleteInput/index.tsx | 122 ++++++++++++++++++ .../SearchAutocompleteInput/shared.ts | 23 ++++ .../Search/components/SearchProfileCard.tsx | 5 +- 9 files changed, 285 insertions(+), 91 deletions(-) create mode 100644 src/screens/Search/components/SearchAutocompleteInput/index.native.tsx create mode 100644 src/screens/Search/components/SearchAutocompleteInput/index.tsx create mode 100644 src/screens/Search/components/SearchAutocompleteInput/shared.ts diff --git a/src/components/Autocomplete/Autocomplete.tsx b/src/components/Autocomplete/Autocomplete.tsx index f49eb6d46a..7a5dfe503e 100644 --- a/src/components/Autocomplete/Autocomplete.tsx +++ b/src/components/Autocomplete/Autocomplete.tsx @@ -1,5 +1,4 @@ import {useCallback} from 'react' -import {View} from 'react-native' import {Sift, type UseSiftReturn} from '@bsky.app/sift' import {atoms as a, useTheme} from '#/alf' @@ -22,7 +21,7 @@ function renderItem( case 'search': return default: - return + return <> } } @@ -33,6 +32,7 @@ export function Autocomplete({ render = renderItem, onSelect, onDismiss, + fullWidth = false, }: { inverted?: boolean sift: UseSiftReturn @@ -40,6 +40,12 @@ export function Autocomplete({ render?: Parameters>[0]['render'] onSelect: (item: AutocompleteItem) => void onDismiss: () => void + /** + * Match the anchor's width instead of the default capped width. Use for + * full-width anchors like the search bar; leave off for inline mention + * inputs. + */ + fullWidth?: boolean }) { const t = useTheme() @@ -50,6 +56,8 @@ export function Autocomplete({ useOnKeyboard('keyboardDidShow', updatePosition) useOnKeyboard('keyboardDidHide', updatePosition) + const maxWidth = IS_WEB && !fullWidth ? {maxWidth: 300} : {} + return ( diff --git a/src/components/Autocomplete/types.ts b/src/components/Autocomplete/types.ts index fef38ab796..6f2610d177 100644 --- a/src/components/Autocomplete/types.ts +++ b/src/components/Autocomplete/types.ts @@ -45,4 +45,5 @@ export type AutocompleteItemProps = Parameters< export type AutocompleteApi = { query: string items: AutocompleteItem[] + isFetching: boolean } diff --git a/src/components/Autocomplete/useAutocomplete/index.ts b/src/components/Autocomplete/useAutocomplete/index.ts index 280faf1f60..498cc766cd 100644 --- a/src/components/Autocomplete/useAutocomplete/index.ts +++ b/src/components/Autocomplete/useAutocomplete/index.ts @@ -118,6 +118,7 @@ export function useAutocomplete({ return { query: q, items, + isFetching: query.isFetching, } } diff --git a/src/screens/Search/Shell.tsx b/src/screens/Search/Shell.tsx index 5eb1fa60e6..20a71b5daf 100644 --- a/src/screens/Search/Shell.tsx +++ b/src/screens/Search/Shell.tsx @@ -23,7 +23,6 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {MagnifyingGlassIcon} from '#/lib/icons' import {type NavigationProp, type SearchParams} from '#/lib/routes/types' import {listenSoftReset} from '#/state/events' -import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import { unstableCacheProfileView, useProfilesQuery, @@ -43,8 +42,8 @@ import { } from '#/screens/Search/searchParams' import {makeSearchQuery} from '#/screens/Search/utils' import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf' +import {useAutocomplete} from '#/components/Autocomplete' import {Button, ButtonIcon} from '#/components/Button' -import {SearchInput} from '#/components/forms/SearchInput' import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight' import * as Layout from '#/components/Layout' @@ -57,6 +56,7 @@ import type * as bsky from '#/types/bsky' import {AdvancedSearchDialog} from './components/AdvancedSearchDialog' import {AutocompleteResults} from './components/AutocompleteResults' import {DetectedLanguagesAdmonition} from './components/DetectedLanguagesAdmonition' +import {SearchAutocompleteInput} from './components/SearchAutocompleteInput' import {SearchHistory} from './components/SearchHistory' import {SearchLanguageDropdown} from './components/SearchLanguageDropdown' import {Explore} from './Explore' @@ -125,8 +125,16 @@ export function SearchScreenShell({ setSearchText(text) }, []) - const {data: autocompleteData, isFetching: isAutocompleteFetching} = - useActorAutocompleteQuery(searchText, true) + const {items: autocompleteItems, isFetching: isAutocompleteFetching} = + useAutocomplete({ + type: 'profile', + /* + * On web the dropdown (SearchAutocompleteInput) owns its own autocomplete + * query; only the native inline list consumes this one, so pass an empty + * query on web to keep the hook a no-op instead of doing wasted work. + */ + query: IS_NATIVE ? searchText : '', + }) const [showAutocomplete, setShowAutocomplete] = useState(false) @@ -388,6 +396,38 @@ export function SearchScreenShell({ [updateProfileHistory, queryClient], ) + /** + * Web only. Selecting a profile from the anchored autocomplete dropdown. + */ + const onSelectProfile = useCallback( + (profile: bsky.profile.AnyProfileView, position: number) => { + ax.metric('search:autocomplete:press', { + profileDid: profile.did, + position, + }) + handleProfileClick(profile) + navigation.navigate('Profile', {name: profile.handle}) + }, + [ax, handleProfileClick, navigation], + ) + + /** + * Web only. Selecting the "Search for X" row from the anchored autocomplete + * dropdown. This runs the typed query as-is (not a suggested profile), so it + * is attributed to `typed` rather than `autocomplete`. + */ + const onSelectSearch = useCallback( + (value: string) => { + ax.metric('search:query', { + source: 'typed', + filterCount: countActiveFilters(filters), + }) + updateSearchText(value) + navigateToItem(value) + }, + [ax, filters, navigateToItem, updateSearchText], + ) + const onSoftReset = useCallback(() => { if (IS_WEB) { /* @@ -495,13 +535,11 @@ export function SearchScreenShell({ {isExplore ? Explore : Search} - {showFilters ? ( - advancedSearchV2Enabled ? null : ( - - ) + {showFilters && !advancedSearchV2Enabled ? ( + ) : ( )} @@ -528,7 +566,7 @@ export function SearchScreenShell({ size="large" variant="ghost" color="secondary" - shape="rectangular" + shape="round" style={[a.px_sm]} onPress={onPressCancelSearch} hitSlop={HITSLOP_10}> @@ -536,7 +574,7 @@ export function SearchScreenShell({ )} - @@ -589,10 +630,10 @@ export function SearchScreenShell({ display: showAutocomplete && !fixedParams ? 'flex' : 'none', flex: 1, }}> - {searchText.length > 0 ? ( + {searchText.length > 0 && IS_NATIVE ? ( void onResultPress: () => void - onProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void + onProfileClick: (profile: bsky.profile.AnyProfileView) => void }): React.ReactNode => { const ax = useAnalytics() - const {_} = useLingui() + const {t: l} = useLingui() const moderationOpts = useModerationOpts() + return ( <> - {(isAutocompleteFetching && !autocompleteData?.length) || - !moderationOpts ? ( + {(isFetching && !items.length) || !moderationOpts ? ( - - + + ) : ( @@ -51,7 +46,7 @@ let AutocompleteResults = ({ keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag"> - {autocompleteData?.map((item, index) => ( - { - ax.metric('search:autocomplete:press', { - profileDid: item.did, - position: index, - }) - onProfileClick(item) - onResultPress() - }} - /> - ))} + {items.map((item, index) => { + if (item.type !== 'profile') return null + return ( + { + ax.metric('search:autocomplete:press', { + profileDid: item.profile.did, + position: index, + }) + onProfileClick(item.profile) + onResultPress() + }} + /> + ) + })} )} @@ -95,12 +93,12 @@ let SearchLinkCard = ({ onPress?: () => void style?: ViewStyle }): React.ReactNode => { - const pal = usePalette('default') + const t = useTheme() const inner = ( - + style={[a.flex_1, a.py_lg, a.px_md, t.atoms.border_contrast_low, style]}> + {label} @@ -117,18 +115,13 @@ let SearchLinkCard = ({ ) } - return ( - - - - {label} - - - - ) + if (to) { + return ( + + {inner} + + ) + } + + return inner } diff --git a/src/screens/Search/components/SearchAutocompleteInput/index.native.tsx b/src/screens/Search/components/SearchAutocompleteInput/index.native.tsx new file mode 100644 index 0000000000..9ac0cb488b --- /dev/null +++ b/src/screens/Search/components/SearchAutocompleteInput/index.native.tsx @@ -0,0 +1,17 @@ +import {SearchInput} from '#/components/forms/SearchInput' +import {type SearchAutocompleteInputProps} from './shared' + +/** + * Native: renders the search input as-is. Typed results are shown inline in the + * full-page list (see Shell.tsx), so there is no anchored dropdown here. See + * index.tsx for the web (floating Sift dropdown) variant. + */ +export function SearchAutocompleteInput({ + // web-only props are ignored on native + fixedParams: _fixedParams, + onSelectProfile: _onSelectProfile, + onSelectSearch: _onSelectSearch, + ...rest +}: SearchAutocompleteInputProps) { + return +} diff --git a/src/screens/Search/components/SearchAutocompleteInput/index.tsx b/src/screens/Search/components/SearchAutocompleteInput/index.tsx new file mode 100644 index 0000000000..0e1cca113e --- /dev/null +++ b/src/screens/Search/components/SearchAutocompleteInput/index.tsx @@ -0,0 +1,122 @@ +import {useRef, useState} from 'react' +import {type TextInput, View} from 'react-native' +import {useSift} from '@bsky.app/sift' + +import {mergeRefs} from '#/lib/merge-refs' +import {atoms as a} from '#/alf' +import { + Autocomplete, + type AutocompleteItem, + useAutocomplete, +} from '#/components/Autocomplete' +import {SearchInput} from '#/components/forms/SearchInput' +import {type SearchAutocompleteInputProps} from './shared' + +/** + * Web: typed results float in a Sift dropdown anchored to the search input + * (matching the desktop nav-bar search). Search history continues to render + * full-page underneath the input in Shell.tsx, so it is not shown here. See + * index.native.tsx for the native (inline list) variant. + */ +export function SearchAutocompleteInput({ + fixedParams, + onSelectProfile, + onSelectSearch, + value = '', + onFocus, + onBlur, + onChangeText, + ref, + ...rest +}: SearchAutocompleteInputProps) { + const [focused, setFocused] = useState(false) + /* + * Sift dismisses on Escape without blurring the input, so `focused` stays + * true. Track that dismissal separately (reset on type or refocus) rather + * than clearing `focused`, otherwise the dropdown wouldn't reopen on the next + * keystroke while the input is still focused. + */ + const [dismissed, setDismissed] = useState(false) + const inputRef = useRef(null) + + const sift = useSift({ + offset: a.p_sm.padding, + placement: 'bottom', + }) + + const active = focused && !dismissed + + const {items} = useAutocomplete({ + type: 'profile', + // The dropdown only shows while active, so don't fetch otherwise. This + // avoids a typeahead request on mount when arriving with text already + // present (e.g. /search?q=foo). + query: active ? value : '', + showSearchFallback: true, + }) + + const showDropdown = + active && !fixedParams && value.length > 0 && items.length > 0 + + function onSelect(item: AutocompleteItem) { + if (item.type === 'profile') { + const position = items.filter(i => i.type === 'profile').indexOf(item) + onSelectProfile?.(item.profile, position) + } else if (item.type === 'search') { + onSelectSearch?.(item.value) + } + inputRef.current?.blur() + } + + /* + * setAnchor goes on the full-width wrapper View so the dropdown matches the + * input's container width; the input ref (targetProps.ref) lands on the inner + * TextInput for Sift's positioning math, and the remaining combobox a11y + * props are spread onto the input. + */ + const {setAnchor} = sift.refs + const {ref: inputAnchorRef, ...comboboxProps} = sift.targetProps + + return ( + void sift.updatePosition()}> + { + setDismissed(false) + onChangeText?.(text) + }} + onFocus={e => { + setDismissed(false) + setFocused(true) + onFocus?.(e) + }} + onBlur={e => { + setFocused(false) + onBlur?.(e) + }} + /> + {showDropdown && ( + setDismissed(true)} + fullWidth + /> + )} + + ) +} diff --git a/src/screens/Search/components/SearchAutocompleteInput/shared.ts b/src/screens/Search/components/SearchAutocompleteInput/shared.ts new file mode 100644 index 0000000000..13f5de5b55 --- /dev/null +++ b/src/screens/Search/components/SearchAutocompleteInput/shared.ts @@ -0,0 +1,23 @@ +import {type SearchInput} from '#/components/forms/SearchInput' +import type * as bsky from '#/types/bsky' + +type SearchInputProps = React.ComponentProps + +export type SearchAutocompleteInputProps = SearchInputProps & { + /** + * When the search has fixed params (e.g. ProfileSearch), the web dropdown is + * suppressed. + */ + fixedParams?: boolean + /** + * Web only. Called when a profile result in the dropdown is selected. + */ + onSelectProfile?: ( + profile: bsky.profile.AnyProfileView, + position: number, + ) => void + /** + * Web only. Called when the "Search for X" row in the dropdown is selected. + */ + onSelectSearch?: (value: string) => void +} diff --git a/src/screens/Search/components/SearchProfileCard.tsx b/src/screens/Search/components/SearchProfileCard.tsx index fdec03c118..a5ca18ee13 100644 --- a/src/screens/Search/components/SearchProfileCard.tsx +++ b/src/screens/Search/components/SearchProfileCard.tsx @@ -1,6 +1,6 @@ import {useCallback} from 'react' import {View} from 'react-native' -import {type AppBskyActorDefs, type ModerationOpts} from '@atproto/api' +import {type ModerationOpts} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' @@ -10,13 +10,14 @@ import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {atoms as a, useTheme} from '#/alf' import {Link} from '#/components/Link' import * as ProfileCard from '#/components/ProfileCard' +import type * as bsky from '#/types/bsky' export function SearchProfileCard({ profile, moderationOpts, onPress: onPressInner, }: { - profile: AppBskyActorDefs.ProfileViewBasic + profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts onPress?: () => void }) {