Files
bsky-social-app/src/screens/Search/components/AdvancedSearchDialog/AutocompleteInput/index.native.tsx
T
2026-06-26 16:16:48 -07:00

154 lines
4.5 KiB
TypeScript

import {useState} from 'react'
import {Pressable, View} from 'react-native'
import {useLingui} from '@lingui/react/macro'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {atoms as a, useTheme} from '#/alf'
import {type AutocompleteItem, useAutocomplete} from '#/components/Autocomplete'
import {Button, ButtonIcon} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import * as ProfileCard from '#/components/ProfileCard'
import {
appendSelection,
type AutocompleteInputProps,
lastTokenOf,
} from './shared'
/**
* Native: Sift's popover positioning doesn't anchor, so the result list is
* drawn as an absolutely-positioned overlay directly above the input. Anchoring
* above (rather than below) keeps it clear of the on-screen keyboard, and
* overlaying (rather than rendering inline) means it doesn't push the focused
* input down under the keyboard. See index.tsx for the web (floating) variant.
* The typeahead matches the last space-delimited token; selecting a result
* completes that token and leaves a trailing space so the next value can be
* typed.
*/
export function AutocompleteInput({
label,
value,
placeholder,
onChangeText,
onSubmitEditing,
}: AutocompleteInputProps) {
const t = useTheme()
const {t: l} = useLingui()
const [focused, setFocused] = useState(false)
const lastToken = lastTokenOf(value)
const {items} = useAutocomplete({type: 'profile', query: lastToken})
const showDropdown = focused && lastToken.length > 0 && items.length > 0
function selectItem(item: AutocompleteItem) {
const next = appendSelection(value, lastToken, item)
if (next !== null) onChangeText(next)
}
return (
<View style={[a.relative]}>
<TextField.Root>
<Dialog.Input
label={label}
value={value}
placeholder={placeholder}
keyboardAppearance={t.scheme}
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
style={[a.pr_2xl]}
onChangeText={onChangeText}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onSubmitEditing={onSubmitEditing}
/>
</TextField.Root>
{value.length > 0 && (
<View
style={[a.absolute, a.justify_center, {top: 0, bottom: 0, right: 8}]}>
<Button
label={l`Clear`}
onPress={() => onChangeText('')}
size="tiny"
color="secondary"
shape="round">
<ButtonIcon icon={XIcon} />
</Button>
</View>
)}
{showDropdown && <OverlayList items={items} onSelect={selectItem} />}
</View>
)
}
function OverlayList({
items,
onSelect,
}: {
items: AutocompleteItem[]
onSelect: (item: AutocompleteItem) => void
}) {
const t = useTheme()
const moderationOpts = useModerationOpts()
if (!moderationOpts) return null
/*
* The list sits above the input, so reverse it to put the most relevant
* result (first) at the bottom, nearest the text the user is typing.
*/
const ordered = [...items].reverse()
return (
<View
style={[
/*
* Drawn as an overlay anchored just above the input so it doesn't push
* the input down under the keyboard. z_10 keeps it above sibling rows.
*/
a.absolute,
a.z_10,
a.mb_xs,
{left: 0, right: 0, bottom: '100%'},
a.border,
a.rounded_sm,
a.overflow_hidden,
t.atoms.border_contrast_low,
t.atoms.bg,
]}>
{ordered.map((item, index) => {
if (item.type !== 'profile') return null
return (
<Pressable
key={item.key}
accessibilityRole="button"
onPress={() => onSelect(item)}
style={({pressed}) => [
a.py_sm,
a.px_md,
index !== 0 && a.border_t,
t.atoms.border_contrast_low,
pressed && t.atoms.bg_contrast_25,
]}>
<ProfileCard.Header>
<ProfileCard.Avatar
disabledPreview
profile={item.profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={item.profile}
moderationOpts={moderationOpts}
/>
</ProfileCard.Header>
</Pressable>
)
})}
</View>
)
}