diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index ad15e84a8f..9ffe56398c 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -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 -}) { - const t = useTheme() - - return ( - - {({focused, hovered, pressed}) => ( - - {label} - - )} - - ) -} - export function DesktopSearch() { const {_} = useLingui() const t = useTheme() const navigation = useNavigation() + const qc = useQueryClient() const searchInputRef = useRef(null) - const [isFocused, setIsFocused] = useState(false) + const [open, setOpen] = useState(false) const [query, setQuery] = useState('') const deferredQuery = useDeferredValue(query) + const [activeIndex, setActiveIndex] = useState(null) + const listRef = useRef>([]) + 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 ( - 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. */} +
+ } + {...(refAriaProps as React.HTMLAttributes)} + style={{width: '100%'}}> + 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) + }} + /> +
+ {open && moderationOpts && ( ) : ( <> - 0 || isFetching) && - a.border_b, - ]} - /> - {isFetching && !autocompleteData?.length ? ( - - + {/* Search link option */} +
{ + 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() + }, + })}> + + {_(msg`Search for "${deferredQuery}"`)} + +
+ + {/* Loading state */} + {isFetching && !profiles.length ? ( + + ) : ( - autocompleteData?.map(item => ( - - )) + profiles.map((profile, i) => { + const itemIndex = 1 + i + return ( +
{ + 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) + }, + })}> + + + + + + +
+ ) + }) )} )}