use floating-ui/react to make an accessible autocomplete

This commit is contained in:
Samuel Newman
2026-02-19 15:27:56 +02:00
parent d8026315bc
commit c60181f731
+203 -83
View File
@@ -1,109 +1,177 @@
import {useDeferredValue, useRef, useState} from 'react'
import {type Role, type TextInput, View} from 'react-native'
import {
ActivityIndicator,
type StyleProp,
type TextInput,
View,
type ViewStyle,
} from 'react-native'
useDismiss,
useFloating,
useId,
useInteractions,
useListNavigation,
useRole,
} from '@floating-ui/react'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {StackActions, useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {type NavigationProp} from '#/lib/routes/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {SearchProfileCard} from '#/screens/Search/components/SearchProfileCard'
import {atoms as a, useTheme} from '#/alf'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {atoms as a, flatten, useTheme} from '#/alf'
import {SearchInput} from '#/components/forms/SearchInput'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
import {Link, type LinkProps} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
function SearchLinkCard({
label,
to,
style,
}: {
label: string
to: LinkProps['to']
style?: StyleProp<ViewStyle>
}) {
const t = useTheme()
return (
<Link to={to} label={label}>
{({focused, hovered, pressed}) => (
<View
style={[
a.w_full,
t.atoms.border_contrast_low,
a.py_lg,
a.px_md,
(focused || hovered || pressed) && t.atoms.bg_contrast_25,
style,
]}>
<Text style={[a.text_sm, a.leading_snug]}>{label}</Text>
</View>
)}
</Link>
)
}
export function DesktopSearch() {
const {_} = useLingui()
const t = useTheme()
const navigation = useNavigation<NavigationProp>()
const qc = useQueryClient()
const searchInputRef = useRef<TextInput>(null)
const [isFocused, setIsFocused] = useState(false)
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const [activeIndex, setActiveIndex] = useState<number | null>(null)
const listRef = useRef<Array<HTMLElement | null>>([])
const {data: autocompleteData, isFetching} = useActorAutocompleteQuery(
deferredQuery,
true,
)
const moderationOpts = useModerationOpts()
const profiles = autocompleteData ?? []
const hasSearchLink = deferredQuery.length > 0
// Floating UI setup — used for interaction hooks (ARIA combobox pattern),
// not for positioning (we keep the existing CSS absolute layout).
const {refs, context} = useFloating({
open,
onOpenChange(nextOpen, _event, reason) {
setOpen(nextOpen)
if (!nextOpen && reason === 'escape-key') {
setQuery('')
searchInputRef.current?.blur()
}
},
})
const role = useRole(context, {role: 'listbox'})
const dismiss = useDismiss(context)
const listNav = useListNavigation(context, {
listRef,
activeIndex,
onNavigate: setActiveIndex,
virtual: true,
loop: true,
})
const {getReferenceProps, getFloatingProps, getItemProps} = useInteractions([
role,
dismiss,
listNav,
])
const listboxId = useId()
const navigateToSearch = () => {
if (!deferredQuery.length) return
navigation.dispatch(StackActions.push('Search', {q: deferredQuery}))
setQuery('')
setOpen(false)
searchInputRef.current?.blur()
}
const navigateToProfile = (profileIndex: number) => {
const profile = profiles[profileIndex]
if (!profile) return
unstableCacheProfileView(qc, profile)
navigation.dispatch(StackActions.push('Profile', {name: profile.did}))
setQuery('')
setOpen(false)
searchInputRef.current?.blur()
}
const selectItem = (index: number) => {
if (hasSearchLink && index === 0) {
navigateToSearch()
} else {
navigateToProfile(hasSearchLink ? index - 1 : index)
}
}
const onChangeText = (text: string) => {
setQuery(text)
if (!open) setOpen(true)
setActiveIndex(text.length > 0 ? 0 : null)
}
const onPressCancelSearch = () => {
setQuery('')
setOpen(false)
}
const onEscape = () => {
setQuery('')
searchInputRef.current?.blur()
}
// getReferenceProps produces the merged keyboard + ARIA props.
// We must use onKeyDownCapture because RNW's TextInput internally calls
// stopPropagation() on all keydown events, preventing normal bubbling.
// Capture phase (parent→child) fires before the target's handler.
const referenceProps = getReferenceProps({
onKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault()
if (activeIndex != null) {
selectItem(activeIndex)
} else {
navigateToSearch()
}
}
},
})
const {onKeyDown: refOnKeyDown, ...refAriaProps} = referenceProps
const onSubmit = () => {
if (!deferredQuery.length) return
navigation.dispatch(StackActions.push('Search', {q: deferredQuery}))
setQuery('')
}
const onSearchProfileCardPress = () => {
setQuery('')
}
// Extract role/id from floating props for the listbox View
const floatingProps = getFloatingProps()
return (
<View style={[a.w_full, a.z_10]}>
<SearchInput
ref={searchInputRef}
value={query}
onChangeText={onChangeText}
onClearText={onPressCancelSearch}
onEscape={onEscape}
onSubmitEditing={onSubmit}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
{(deferredQuery !== '' || isFocused) && moderationOpts && (
{/* Wrapper div receives floating-ui reference + ARIA props.
onKeyDownCapture is needed because RNW's TextInput stops keydown
propagation — capture phase fires before that happens. */}
<div
ref={refs.setReference}
onKeyDownCapture={
refOnKeyDown as React.KeyboardEventHandler<HTMLDivElement>
}
{...(refAriaProps as React.HTMLAttributes<HTMLDivElement>)}
style={{width: '100%'}}>
<SearchInput
ref={searchInputRef}
value={query}
onChangeText={onChangeText}
onClearText={onPressCancelSearch}
onFocus={() => setOpen(true)}
onBlur={(e: any) => {
const relatedTarget = (e as React.FocusEvent)
.relatedTarget as Node | null
if (
relatedTarget &&
refs.floating.current?.contains(relatedTarget)
) {
return
}
setOpen(false)
}}
/>
</div>
{open && moderationOpts && (
<View style={[a.w_full]}>
<View
ref={refs.setFloating}
role={floatingProps.role as Role}
id={floatingProps.id as string}
style={[
t.atoms.bg,
t.atoms.border_contrast_low,
@@ -126,27 +194,79 @@ export function DesktopSearch() {
</View>
) : (
<>
<SearchLinkCard
label={_(msg`Search for "${deferredQuery}"`)}
to={{screen: 'Search', params: {q: deferredQuery}}}
style={[
((autocompleteData?.length ?? 0) > 0 || isFetching) &&
a.border_b,
]}
/>
{isFetching && !autocompleteData?.length ? (
<View style={[a.p_md]}>
<ActivityIndicator />
{/* Search link option */}
<div
ref={node => {
listRef.current[0] = node
}}
id={`${listboxId}-option-0`}
role="option"
aria-selected={activeIndex === 0}
style={flatten([
a.w_full,
a.py_lg,
a.px_md,
a.pointer,
(profiles.length > 0 || isFetching) && a.border_b,
t.atoms.border_contrast_low,
activeIndex === 0 && t.atoms.bg_contrast_25,
])}
{...getItemProps({
onClick() {
navigateToSearch()
},
})}>
<Text style={[a.text_sm, a.leading_snug]}>
{_(msg`Search for "${deferredQuery}"`)}
</Text>
</div>
{/* Loading state */}
{isFetching && !profiles.length ? (
<View style={[a.p_xl, a.align_center]}>
<Loader size="md" />
</View>
) : (
autocompleteData?.map(item => (
<SearchProfileCard
key={item.did}
profile={item}
moderationOpts={moderationOpts}
onPress={onSearchProfileCardPress}
/>
))
profiles.map((profile, i) => {
const itemIndex = 1 + i
return (
<div
key={profile.did}
ref={node => {
listRef.current[itemIndex] = node
}}
id={`${listboxId}-option-${itemIndex}`}
role="option"
aria-selected={activeIndex === itemIndex}
aria-label={_(msg`View ${profile.handle}'s profile`)}
style={flatten([
a.flex,
a.flex_col,
{paddingLeft: 6, paddingRight: 6},
a.px_sm,
a.pointer,
activeIndex === itemIndex && t.atoms.bg_contrast_25,
])}
{...getItemProps({
onClick() {
navigateToProfile(i)
},
})}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
</ProfileCard.Header>
</ProfileCard.Outer>
</div>
)
})
)}
</>
)}