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 {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>
|
||||||
|
)
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user