use floating-ui/react to make an accessible autocomplete
This commit is contained in:
@@ -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>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user