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
}) {