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 {useDeferredValue, useRef, useState} from 'react'
import {type Role, type TextInput, View} from 'react-native'
import { import {
ActivityIndicator, useDismiss,
type StyleProp, useFloating,
type TextInput, useId,
View, useInteractions,
type ViewStyle, useListNavigation,
} from 'react-native' useRole,
} from '@floating-ui/react'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {StackActions, useNavigation} from '@react-navigation/native' import {StackActions, useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {SearchProfileCard} from '#/screens/Search/components/SearchProfileCard' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, flatten, useTheme} from '#/alf'
import {SearchInput} from '#/components/forms/SearchInput' import {SearchInput} from '#/components/forms/SearchInput'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' 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' 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() { export function DesktopSearch() {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const qc = useQueryClient()
const searchInputRef = useRef<TextInput>(null) const searchInputRef = useRef<TextInput>(null)
const [isFocused, setIsFocused] = useState(false) const [open, setOpen] = useState(false)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query) const deferredQuery = useDeferredValue(query)
const [activeIndex, setActiveIndex] = useState<number | null>(null)
const listRef = useRef<Array<HTMLElement | null>>([])
const {data: autocompleteData, isFetching} = useActorAutocompleteQuery( const {data: autocompleteData, isFetching} = useActorAutocompleteQuery(
deferredQuery, deferredQuery,
true, true,
) )
const moderationOpts = useModerationOpts() 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) => { const onChangeText = (text: string) => {
setQuery(text) setQuery(text)
if (!open) setOpen(true)
setActiveIndex(text.length > 0 ? 0 : null)
} }
const onPressCancelSearch = () => { const onPressCancelSearch = () => {
setQuery('') setQuery('')
setOpen(false)
} }
const onEscape = () => { // getReferenceProps produces the merged keyboard + ARIA props.
setQuery('') // We must use onKeyDownCapture because RNW's TextInput internally calls
searchInputRef.current?.blur() // 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 = () => { // Extract role/id from floating props for the listbox View
if (!deferredQuery.length) return const floatingProps = getFloatingProps()
navigation.dispatch(StackActions.push('Search', {q: deferredQuery}))
setQuery('')
}
const onSearchProfileCardPress = () => {
setQuery('')
}
return ( return (
<View style={[a.w_full, a.z_10]}> <View style={[a.w_full, a.z_10]}>
<SearchInput {/* Wrapper div receives floating-ui reference + ARIA props.
ref={searchInputRef} onKeyDownCapture is needed because RNW's TextInput stops keydown
value={query} propagation — capture phase fires before that happens. */}
onChangeText={onChangeText} <div
onClearText={onPressCancelSearch} ref={refs.setReference}
onEscape={onEscape} onKeyDownCapture={
onSubmitEditing={onSubmit} refOnKeyDown as React.KeyboardEventHandler<HTMLDivElement>
onFocus={() => setIsFocused(true)} }
onBlur={() => setIsFocused(false)} {...(refAriaProps as React.HTMLAttributes<HTMLDivElement>)}
/> style={{width: '100%'}}>
{(deferredQuery !== '' || isFocused) && moderationOpts && ( <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 style={[a.w_full]}>
<View <View
ref={refs.setFloating}
role={floatingProps.role as Role}
id={floatingProps.id as string}
style={[ style={[
t.atoms.bg, t.atoms.bg,
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
@@ -126,27 +194,79 @@ export function DesktopSearch() {
</View> </View>
) : ( ) : (
<> <>
<SearchLinkCard {/* Search link option */}
label={_(msg`Search for "${deferredQuery}"`)} <div
to={{screen: 'Search', params: {q: deferredQuery}}} ref={node => {
style={[ listRef.current[0] = node
((autocompleteData?.length ?? 0) > 0 || isFetching) && }}
a.border_b, id={`${listboxId}-option-0`}
]} role="option"
/> aria-selected={activeIndex === 0}
{isFetching && !autocompleteData?.length ? ( style={flatten([
<View style={[a.p_md]}> a.w_full,
<ActivityIndicator /> 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> </View>
) : ( ) : (
autocompleteData?.map(item => ( profiles.map((profile, i) => {
<SearchProfileCard const itemIndex = 1 + i
key={item.did} return (
profile={item} <div
moderationOpts={moderationOpts} key={profile.did}
onPress={onSearchProfileCardPress} 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>
)
})
)} )}
</> </>
)} )}