Close web mention suggestions popup on Escape (#8605)

* alf web typeahead

* fix type error

* fix escape behaviour

* change selection on hover

* rm React.

* undo random change
This commit is contained in:
Samuel Newman
2025-08-29 03:03:12 +03:00
committed by GitHub
parent 27c1058568
commit 8a4398608a
5 changed files with 251 additions and 271 deletions
@@ -1,6 +1,6 @@
import {forwardRef, useEffect, useImperativeHandle, useState} from 'react'
import {Pressable, StyleSheet, View} from 'react-native'
import {type AppBskyActorDefs} from '@atproto/api'
import {Pressable, View} from 'react-native'
import {type AppBskyActorDefs, type ModerationOpts} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {ReactRenderer} from '@tiptap/react'
import {
@@ -10,25 +10,26 @@ import {
} from '@tiptap/suggestion'
import tippy, {type Instance as TippyInstance} from 'tippy.js'
import {usePalette} from '#/lib/hooks/usePalette'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {type ActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
import {Text} from '#/view/com/util/text/Text'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a} from '#/alf'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {useGrapheme} from '../hooks/useGrapheme'
import {atoms as a, useTheme} from '#/alf'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
interface MentionListRef {
onKeyDown: (props: SuggestionKeyDownProps) => boolean
}
export interface AutocompleteRef {
maybeClose: () => boolean
}
export function createSuggestion({
autocomplete,
autocompleteRef,
}: {
autocomplete: ActorAutocompleteFn
autocompleteRef: React.Ref<AutocompleteRef>
}): Omit<SuggestionOptions, 'editor'> {
return {
async items({query}) {
@@ -40,10 +41,15 @@ export function createSuggestion({
let component: ReactRenderer<MentionListRef> | undefined
let popup: TippyInstance[] | undefined
const hide = () => {
popup?.[0]?.destroy()
component?.destroy()
}
return {
onStart: props => {
component = new ReactRenderer(MentionList, {
props,
props: {...props, autocompleteRef, hide},
editor: props.editor,
})
@@ -78,204 +84,163 @@ export function createSuggestion({
onKeyDown(props) {
if (props.event.key === 'Escape') {
popup?.[0]?.hide()
return true
return false
}
return component?.ref?.onKeyDown(props) || false
},
onExit() {
popup?.[0]?.destroy()
component?.destroy()
hide()
},
}
},
}
}
const MentionList = forwardRef<MentionListRef, SuggestionProps>(
function MentionListImpl(props: SuggestionProps, ref) {
const [selectedIndex, setSelectedIndex] = useState(0)
const pal = usePalette('default')
const MentionList = forwardRef<
MentionListRef,
SuggestionProps & {
autocompleteRef: React.Ref<AutocompleteRef>
hide: () => void
}
>(function MentionListImpl({items, command, hide, autocompleteRef}, ref) {
const [selectedIndex, setSelectedIndex] = useState(0)
const t = useTheme()
const moderationOpts = useModerationOpts()
const selectItem = (index: number) => {
const item = props.items[index]
const selectItem = (index: number) => {
const item = items[index]
if (item) {
props.command({id: item.handle})
if (item) {
command({id: item.handle})
}
}
const upHandler = () => {
setSelectedIndex((selectedIndex + items.length - 1) % items.length)
}
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % items.length)
}
const enterHandler = () => {
selectItem(selectedIndex)
}
useEffect(() => setSelectedIndex(0), [items])
useImperativeHandle(autocompleteRef, () => ({
maybeClose: () => {
hide()
return true
},
}))
useImperativeHandle(ref, () => ({
onKeyDown: ({event}) => {
if (event.key === 'ArrowUp') {
upHandler()
return true
}
}
const upHandler = () => {
setSelectedIndex(
(selectedIndex + props.items.length - 1) % props.items.length,
)
}
if (event.key === 'ArrowDown') {
downHandler()
return true
}
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length)
}
if (event.key === 'Enter' || event.key === 'Tab') {
enterHandler()
return true
}
const enterHandler = () => {
selectItem(selectedIndex)
}
return false
},
}))
useEffect(() => setSelectedIndex(0), [props.items])
if (!moderationOpts) return null
useImperativeHandle(ref, () => ({
onKeyDown: ({event}) => {
if (event.key === 'ArrowUp') {
upHandler()
return true
}
return (
<div className="items">
<View
style={[
t.atoms.border_contrast_low,
t.atoms.bg,
a.rounded_sm,
a.border,
a.p_xs,
{width: 300},
]}>
{items.length > 0 ? (
items.map((item, index) => {
const isSelected = selectedIndex === index
if (event.key === 'ArrowDown') {
downHandler()
return true
}
if (event.key === 'Enter' || event.key === 'Tab') {
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 isSelected = selectedIndex === index
return (
<AutocompleteProfileCard
key={item.handle}
profile={item}
isSelected={isSelected}
itemIndex={index}
totalItems={items.length}
onPress={() => {
selectItem(index)
}}
/>
)
})
) : (
<Text type="sm" style={[pal.text, styles.noResult]}>
<Trans>No result</Trans>
</Text>
)}
</View>
</div>
)
},
)
return (
<AutocompleteProfileCard
key={item.handle}
profile={item}
isSelected={isSelected}
onPress={() => selectItem(index)}
onHover={() => setSelectedIndex(index)}
moderationOpts={moderationOpts}
/>
)
})
) : (
<Text style={[a.text_sm, a.px_md, a.py_md]}>
<Trans>No result</Trans>
</Text>
)}
</View>
</div>
)
})
function AutocompleteProfileCard({
profile,
isSelected,
itemIndex,
totalItems,
onPress,
onHover,
moderationOpts,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
isSelected: boolean
itemIndex: number
totalItems: number
onPress: () => void
onHover: () => void
moderationOpts: ModerationOpts
}) {
const pal = usePalette('default')
const {getGraphemeString} = useGrapheme()
const {name: displayName} = getGraphemeString(
sanitizeDisplayName(profile.displayName || sanitizeHandle(profile.handle)),
30, // Heuristic value; can be modified
)
const state = useSimpleVerificationState({
profile,
})
const t = useTheme()
return (
<Pressable
style={[
isSelected ? pal.viewLight : undefined,
pal.borderDark,
styles.mentionContainer,
itemIndex === 0
? styles.firstMention
: itemIndex === totalItems - 1
? styles.lastMention
: undefined,
isSelected && t.atoms.bg_contrast_25,
a.align_center,
a.justify_between,
a.flex_row,
a.px_md,
a.py_sm,
a.gap_2xl,
a.rounded_xs,
a.transition_color,
]}
onPress={onPress}
onPointerEnter={onHover}
accessibilityRole="button">
<View style={[styles.avatarAndDisplayName, a.flex_1]}>
<UserAvatar
avatar={profile.avatar ?? null}
size={26}
type={profile.associated?.labeler ? 'labeler' : 'user'}
/>
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
<Text emoji style={[pal.text]} numberOfLines={1}>
{displayName}
</Text>
{state.isVerified && (
<View>
<VerificationCheck
width={12}
verifier={state.role === 'verifier'}
/>
</View>
)}
</View>
</View>
<View>
<Text type="xs" style={pal.textLight} numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
<View style={[a.flex_1]}>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
disabledPreview
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
</ProfileCard.Header>
</View>
</Pressable>
)
}
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: 16,
},
firstMention: {
borderTopLeftRadius: 2,
borderTopRightRadius: 2,
},
lastMention: {
borderBottomLeftRadius: 2,
borderBottomRightRadius: 2,
},
avatarAndDisplayName: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
noResult: {
paddingHorizontal: 12,
paddingVertical: 8,
},
})