bsky-app/src/view/com/composer/text-input/web/Autocomplete.tsx
2023-05-15 13:12:38 -05:00

235 lines
6 KiB
TypeScript

import React, {
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from 'react'
import {Pressable, StyleSheet, View} from 'react-native'
import {ReactRenderer} from '@tiptap/react'
import tippy, {Instance as TippyInstance} from 'tippy.js'
import {
SuggestionOptions,
SuggestionProps,
SuggestionKeyDownProps,
} from '@tiptap/suggestion'
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
import {usePalette} from 'lib/hooks/usePalette'
import {Text} from 'view/com/util/text/Text'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {useGrapheme} from '../hooks/useGrapheme'
interface MentionListRef {
onKeyDown: (props: SuggestionKeyDownProps) => boolean
}
export function createSuggestion({
autocompleteView,
}: {
autocompleteView: UserAutocompleteModel
}): Omit<SuggestionOptions, 'editor'> {
return {
async items({query}) {
autocompleteView.setActive(true)
await autocompleteView.setPrefix(query)
return autocompleteView.suggestions.slice(0, 8)
},
render: () => {
let component: ReactRenderer<MentionListRef> | undefined
let popup: TippyInstance[] | undefined
return {
onStart: props => {
component = new ReactRenderer(MentionList, {
props,
editor: props.editor,
})
if (!props.clientRect) {
return
}
// @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf
popup = tippy('body', {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
})
},
onUpdate(props) {
component?.updateProps(props)
if (!props.clientRect) {
return
}
popup?.[0]?.setProps({
// @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf
getReferenceClientRect: props.clientRect,
})
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
popup?.[0]?.hide()
return true
}
return component?.ref?.onKeyDown(props) || false
},
onExit() {
popup?.[0]?.destroy()
component?.destroy()
},
}
},
}
}
const MentionList = forwardRef<MentionListRef, SuggestionProps>(
(props: SuggestionProps, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0)
const pal = usePalette('default')
const {getGraphemeString} = useGrapheme()
const selectItem = (index: number) => {
const item = props.items[index]
if (item) {
props.command({id: item.handle})
}
}
const upHandler = () => {
setSelectedIndex(
(selectedIndex + props.items.length - 1) % props.items.length,
)
}
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length)
}
const enterHandler = () => {
selectItem(selectedIndex)
}
useEffect(() => setSelectedIndex(0), [props.items])
useImperativeHandle(ref, () => ({
onKeyDown: ({event}) => {
if (event.key === 'ArrowUp') {
upHandler()
return true
}
if (event.key === 'ArrowDown') {
downHandler()
return true
}
if (event.key === 'Enter') {
enterHandler()
return true
}
return false
},
}))
const {items} = props
return (
<div className="items">
<View style={[pal.borderDark, pal.view, styles.container]}>
{items.length > 0 ? (
items.map((item, index) => {
const {name: displayName} = getGraphemeString(
item.displayName ?? item.handle,
30, // Heuristic value; can be modified
)
const isSelected = selectedIndex === index
return (
<Pressable
key={item.handle}
style={[
isSelected ? pal.viewLight : undefined,
pal.borderDark,
styles.mentionContainer,
index === 0
? styles.firstMention
: index === items.length - 1
? styles.lastMention
: undefined,
]}
onPress={() => {
selectItem(index)
}}
accessibilityRole="button">
<View style={styles.avatarAndDisplayName}>
<UserAvatar avatar={item.avatar ?? null} size={26} />
<Text style={pal.text} numberOfLines={1}>
{displayName}
</Text>
</View>
<Text type="xs" style={pal.textLight} numberOfLines={1}>
@{item.handle}
</Text>
</Pressable>
)
})
) : (
<Text type="sm" style={[pal.text, styles.noResult]}>
No result
</Text>
)}
</View>
</div>
)
},
)
const styles = StyleSheet.create({
container: {
width: 500,
borderRadius: 6,
borderWidth: 1,
borderStyle: 'solid',
padding: 4,
},
mentionContainer: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: 'row',
paddingHorizontal: 12,
paddingVertical: 8,
gap: 4,
},
firstMention: {
borderTopLeftRadius: 2,
borderTopRightRadius: 2,
},
lastMention: {
borderBottomLeftRadius: 2,
borderBottomRightRadius: 2,
},
avatarAndDisplayName: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
noResult: {
paddingHorizontal: 12,
paddingVertical: 8,
},
})