Limit height of autocomplete, adjust emoji autocomplete design

This commit is contained in:
Eric Bailey
2026-04-05 15:46:28 -05:00
parent 9f3bd40d1d
commit facb0d36d8
8 changed files with 153 additions and 137 deletions
+2 -2
View File
@@ -88,8 +88,8 @@
"@bsky.app/expo-image-crop-tool": "^0.5.0", "@bsky.app/expo-image-crop-tool": "^0.5.0",
"@bsky.app/expo-translate-text": "^0.2.9", "@bsky.app/expo-translate-text": "^0.2.9",
"@bsky.app/react-native-mmkv": "2.12.5", "@bsky.app/react-native-mmkv": "2.12.5",
"@bsky.app/sift": "^0.2.6", "@bsky.app/sift": "^0.2.8",
"@bsky.app/tapper": "^0.4.1", "@bsky.app/tapper": "^0.4.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
@@ -19,11 +19,14 @@ export function AutocompleteItemEmoji({
<SiftItem <SiftItem
{...props} {...props}
style={s => [ style={s => [
a.px_md, {paddingVertical: 6, paddingHorizontal: 10},
a.py_sm, a.flex_row,
a.align_center,
a.gap_sm,
active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [], active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [],
]}> ]}>
<Text>{item.value}</Text> <Text style={[a.text_xl, a.leading_tight]}>{item.value}</Text>
<Text style={[a.text_md, a.leading_tight]}>:{item.emoji.id}:</Text>
</SiftItem> </SiftItem>
) )
} }
+2 -1
View File
@@ -1,4 +1,5 @@
import {type Sift} from '@bsky.app/sift' import {type Sift} from '@bsky.app/sift'
import {type Emoji} from '@emoji-mart/data'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
@@ -20,7 +21,7 @@ export type AutocompleteEmoji = {
key: string key: string
type: 'emoji' type: 'emoji'
value: string value: string
emoji: string emoji: Emoji
} }
export type AutocompleteItem = export type AutocompleteItem =
@@ -92,7 +92,7 @@ export function useAutocomplete({
key: result.item.id, key: result.item.id,
type: 'emoji' as const, type: 'emoji' as const,
value: result.item.skins[0].native, value: result.item.skins[0].native,
emoji: result.item.skins[0].native, emoji: result.item,
})) }))
} }
+43 -41
View File
@@ -1,9 +1,3 @@
/**
* TODO
*
* Native
* - make sure we limit the height of autocomplete so it doesn't go off screen
*/
import { import {
useCallback, useCallback,
useEffect, useEffect,
@@ -14,7 +8,6 @@ import {
} from 'react' } from 'react'
import { import {
type TextInput, type TextInput,
type TextInputProps,
type TextInputSubmitEditingEvent, type TextInputSubmitEditingEvent,
View, View,
} from 'react-native' } from 'react-native'
@@ -22,8 +15,10 @@ import Animated, {
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useSift, type UseSiftReturn} from '@bsky.app/sift' import {useSift, type UseSiftReturn} from '@bsky.app/sift'
import { import {
facets,
type TapperActiveFacet, type TapperActiveFacet,
type TapperFacet, type TapperFacet,
useTapper, useTapper,
@@ -45,7 +40,10 @@ import {
parseAutocompleteItemType, parseAutocompleteItemType,
useAutocomplete, useAutocomplete,
} from '#/components/Autocomplete' } from '#/components/Autocomplete'
import {AutosizedTextarea} from '#/components/forms/AutosizedTextarea' import {
AutosizedTextarea,
type AutosizedTextareaProps,
} from '#/components/forms/AutosizedTextarea'
import {useOnKeyboard} from '#/components/hooks/useOnKeyboard' import {useOnKeyboard} from '#/components/hooks/useOnKeyboard'
import {Span, Text} from '#/components/Typography' import {Span, Text} from '#/components/Typography'
import {IS_IOS, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env' import {IS_IOS, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env'
@@ -71,6 +69,7 @@ export type ComposerInternalApi = {
input?: ReturnType<typeof useTapper>['input'] input?: ReturnType<typeof useTapper>['input']
clear: () => void clear: () => void
insert(text: string): void insert(text: string): void
setAnchorRef(ref: View | null): void
} }
export function useComposerInternalApiRef() { export function useComposerInternalApiRef() {
@@ -82,7 +81,7 @@ export function useComposerInternalApiRef() {
*/ */
export type ComposerProps = Omit< export type ComposerProps = Omit<
TextInputProps, AutosizedTextareaProps,
| 'value' | 'value'
| 'onChange' | 'onChange'
| 'onChangeText' | 'onChangeText'
@@ -91,57 +90,65 @@ export type ComposerProps = Omit<
| 'style' | 'style'
| 'onSubmitEditing' | 'onSubmitEditing'
> & { > & {
children?: React.ReactNode
label: string label: string
ref?: React.Ref<TextInput> ref?: React.Ref<TextInput>
style?: ViewStyleProp['style'] internalApiRef?: React.Ref<ComposerInternalApi>
padding?: { outerStyle?: ViewStyleProp['style']
contentTextStyle?: TextStyleProp['style']
contentPaddingStyle?: {
paddingTop?: number paddingTop?: number
paddingBottom?: number paddingBottom?: number
paddingLeft?: number paddingLeft?: number
paddingRight?: number paddingRight?: number
} }
textStyle?: TextStyleProp['style']
maxNumberOfLines?: number
initialText?: string
onChange?: (text: string) => void onChange?: (text: string) => void
onActiveFacet?: (activeFacet: TapperActiveFacet | null) => void onActiveFacet?: (activeFacet: TapperActiveFacet | null) => void
onFacetCommitted?: (facet: TapperFacet) => void onFacetCommitted?: (facet: TapperFacet) => void
onRequestSubmit?: (request: SubmitRequest) => void onRequestSubmit?: (request: SubmitRequest) => void
internalApiRef?: React.Ref<ComposerInternalApi>
autocompletePlacement?: Exclude< autocompletePlacement?: Exclude<
Parameters<typeof useSift>[0], Parameters<typeof useSift>[0],
undefined undefined
>['placement'] >['placement']
disableEmojiFacets?: boolean
} }
export function Composer({ export function Composer({
children,
label, label,
placeholder, ref,
style, internalApiRef,
padding, outerStyle,
textStyle: rawTextStyle, contentTextStyle,
maxNumberOfLines, contentPaddingStyle,
initialText,
onChange: onChangeOuter, onChange: onChangeOuter,
onActiveFacet: onActiveFacetOuter, onActiveFacet: onActiveFacetOuter,
onFacetCommitted: onFacetCommittedOuter, onFacetCommitted: onFacetCommittedOuter,
onRequestSubmit, onRequestSubmit,
internalApiRef,
autocompletePlacement, autocompletePlacement,
defaultValue,
disableEmojiFacets = !IS_WEB,
...rest ...rest
}: ComposerProps) { }: ComposerProps) {
const {theme: t, fonts} = useAlf() const {theme: t, fonts} = useAlf()
const insets = useSafeAreaInsets()
/* /*
* Meat and potatoes * Meat and potatoes
*/ */
const tapper = useTapper({initialText}) const tapper = useTapper({
initialText: defaultValue ?? '',
facets: disableEmojiFacets
? {
mention: facets.mention,
tag: facets.tag,
url: facets.url,
}
: facets,
})
const sift = useSift({ const sift = useSift({
offset: a.p_sm.padding, offset: a.p_sm.padding,
placement: autocompletePlacement, placement: autocompletePlacement,
dynamicWidth: IS_WEB, dynamicWidth: IS_WEB,
insets,
}) })
/* /*
@@ -166,8 +173,11 @@ export function Composer({
inputScrollSharedValue.value = 0 inputScrollSharedValue.value = 0
}, },
insert: tapper.insert, insert: tapper.insert,
setAnchorRef: (ref: View | null) => {
sift.refs.setAnchor(ref)
},
}), }),
[tapper.input, tapper.insert, inputScrollSharedValue], [tapper.input, tapper.insert, inputScrollSharedValue, sift.refs],
) )
/* /*
@@ -220,7 +230,7 @@ export function Composer({
})) }))
const textStyle = useMemo(() => { const textStyle = useMemo(() => {
const ts = normalizeTextStyles( const ts = normalizeTextStyles(
[a.leading_snug, rawTextStyle, t.atoms.text], [a.leading_snug, t.atoms.text, contentTextStyle],
{ {
fontScale: fonts.scaleMultiplier, fontScale: fonts.scaleMultiplier,
fontFamily: fonts.family, fontFamily: fonts.family,
@@ -240,7 +250,7 @@ export function Composer({
delete ts.lineHeight delete ts.lineHeight
} }
return ts return ts
}, [rawTextStyle, fonts]) }, [contentTextStyle, fonts])
/* /*
* Web keyboard handling * Web keyboard handling
@@ -282,13 +292,13 @@ export function Composer({
return ( return (
<> <>
<View style={[a.relative, style]}> <View style={[a.relative, outerStyle]}>
<View <View
pointerEvents="none" pointerEvents="none"
style={[a.absolute, a.inset_0, a.z_10, {overflow: 'hidden'}]}> style={[a.absolute, a.inset_0, a.z_10, {overflow: 'hidden'}]}>
<Animated.View <Animated.View
style={[ style={[
padding, contentPaddingStyle,
{position: 'absolute', left: 0, right: 0}, {position: 'absolute', left: 0, right: 0},
previewScrollStyle, previewScrollStyle,
]}> ]}>
@@ -317,8 +327,6 @@ export function Composer({
</Animated.View> </Animated.View>
</View> </View>
<AutosizedTextarea <AutosizedTextarea
maxRows={12}
placeholder={placeholder}
placeholderTextColor={t.palette.contrast_500} placeholderTextColor={t.palette.contrast_500}
accessibilityLabel={label} accessibilityLabel={label}
accessibilityHint={label} accessibilityHint={label}
@@ -327,7 +335,7 @@ export function Composer({
}} }}
style={[ style={[
textStyle, textStyle,
padding, contentPaddingStyle,
a.z_20, a.z_20,
{ {
color: 'transparent', color: 'transparent',
@@ -341,11 +349,7 @@ export function Composer({
{...rest} {...rest}
{...tapper.inputProps} {...tapper.inputProps}
{...sift.targetProps} {...sift.targetProps}
ref={mergeRefs([ ref={mergeRefs([ref, tapper.inputProps.ref, sift.targetProps.ref])}
rest.ref,
tapper.inputProps.ref,
sift.targetProps.ref,
])}
onBlur={e => { onBlur={e => {
rest.onBlur?.(e) rest.onBlur?.(e)
setActiveFacet(null) setActiveFacet(null)
@@ -368,11 +372,9 @@ export function Composer({
}} }}
onUpdateHeight={updateAutocompletePosition} onUpdateHeight={updateAutocompletePosition}
/> />
{children}
</View> </View>
{activeFacet && ( {activeFacet && activeFacet.type !== 'url' && (
<AutocompleteInner <AutocompleteInner
sift={sift} sift={sift}
activeFacet={activeFacet} activeFacet={activeFacet}
+9 -7
View File
@@ -10,6 +10,14 @@ import {atoms as a, extractPadding, useAlf, web} from '#/alf'
import {normalizeTextStyles} from '#/alf/typography' import {normalizeTextStyles} from '#/alf/typography'
import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env' import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env'
export type AutosizedTextareaProps = Omit<TextInputProps, 'multiline'> & {
ref?: React.Ref<TextInput>
label: string
minRows?: number
maxRows?: number
onUpdateHeight?: (height: number) => void
}
export function AutosizedTextarea({ export function AutosizedTextarea({
ref, ref,
label, label,
@@ -21,13 +29,7 @@ export function AutosizedTextarea({
onContentSizeChange: onContentSizeChangeOuter, onContentSizeChange: onContentSizeChangeOuter,
style: outerStyle, style: outerStyle,
...rest ...rest
}: Omit<TextInputProps, 'multiline'> & { }: AutosizedTextareaProps) {
ref?: React.Ref<TextInput>
label: string
minRows?: number
maxRows?: number
onUpdateHeight?: (height: number) => void
}) {
const {theme: t, fonts} = useAlf() const {theme: t, fonts} = useAlf()
const internalRef = useRef<TextInput>(null) const internalRef = useRef<TextInput>(null)
const {style, minInputHeight, maxInputHeight, verticalContentPadding} = const {style, minInputHeight, maxInputHeight, verticalContentPadding} =
@@ -23,7 +23,7 @@ import {useInteractionState} from '#/components/hooks/useInteractionState'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {IS_ANDROID, IS_WEB} from '#/env' import {IS_WEB} from '#/env'
export function MessageComposer({ export function MessageComposer({
onSendMessage, onSendMessage,
@@ -107,25 +107,84 @@ export function MessageComposer({
<> <>
<View style={[a.px_md, a.pb_sm, a.pt_xs]}> <View style={[a.px_md, a.pb_sm, a.pt_xs]}>
{children} {children}
<View <View
ref={node => {
if (!IS_WEB && node) {
composerInternalApiRef.current?.setAnchorRef(node)
}
}}
// @ts-expect-error web only // @ts-expect-error web only
onMouseEnter={onHoverIn} onMouseEnter={onHoverIn}
onMouseLeave={onHoverOut}> onMouseLeave={onHoverOut}
style={[a.w_full, a.flex_row, a.gap_sm]}>
{IS_WEB && (
<Pressable
onPress={e => {
e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => {
openEmojiPicker?.({
top: py,
left: px,
right: px,
bottom: py,
nextFocusRef: {
current: composerInternalApiRef.current?.input?.element,
},
})
})
}}
style={[
a.overflow_hidden,
a.absolute,
a.rounded_full,
a.align_center,
a.justify_center,
a.z_30,
{
height: 30,
width: 30,
top: 8,
left: 8,
},
]}
accessibilityLabel={l`Open emoji picker`}
accessibilityHint="">
{state => (
<View
style={[
a.absolute,
a.inset_0,
a.align_center,
a.justify_center,
{
backgroundColor:
state.hovered || state.focused || state.pressed
? t.atoms.bg.backgroundColor
: undefined,
},
]}>
<EmojiSmile size="lg" />
</View>
)}
</Pressable>
)}
<Composer <Composer
internalApiRef={composerInternalApiRef}
initialText={text}
onChange={setText}
editable={editable}
autoFocus={IS_WEB}
label={l`Message input field`} label={l`Message input field`}
placeholder={l`Write a message`} placeholder={l`Write a message`}
maxNumberOfLines={12} autocompletePlacement="top-start"
style={[ internalApiRef={composerInternalApiRef}
defaultValue={text}
editable={editable}
autoFocus={IS_WEB}
maxRows={12}
outerStyle={[
a.flex_1,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_25,
{ {
borderWidth: 1, borderWidth: 1,
borderColor: 'transparent', borderColor: 'transparent',
borderRadius: 25, borderRadius: 22,
}, },
editable && editable &&
hovered && { hovered && {
@@ -136,15 +195,16 @@ export function MessageComposer({
borderColor: t.palette.primary_500, borderColor: t.palette.primary_500,
}, },
]} ]}
padding={{ contentTextStyle={[a.text_md, a.leading_snug]}
paddingLeft: IS_WEB ? 30 + 8 : 16, contentPaddingStyle={{
paddingLeft: IS_WEB ? 30 + 12 : 12,
paddingTop: 12, paddingTop: 12,
paddingBottom: 12, paddingBottom: 12,
paddingRight: 35 + a.p_sm.padding, paddingRight: 12,
}} }}
textStyle={[a.text_md, a.leading_snug]}
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur} onBlur={onBlur}
onChange={setText}
onFacetCommitted={facet => { onFacetCommitted={facet => {
if (facet.type === 'url' && isBskyPostUrl(facet.value)) { if (facet.type === 'url' && isBskyPostUrl(facet.value)) {
setEmbed(facet.value) setEmbed(facet.value)
@@ -154,77 +214,25 @@ export function MessageComposer({
if (req.platform === 'web' && req.shiftKey) return if (req.platform === 'web' && req.shiftKey) return
req.nativeEvent.preventDefault() req.nativeEvent.preventDefault()
onSubmit() onSubmit()
}}> }}
{IS_WEB && ( />
<Pressable
onPress={e => { {focused || text.length ? (
e.currentTarget.measure(
(_fx, _fy, _width, _height, px, py) => {
openEmojiPicker?.({
top: py,
left: px,
right: px,
bottom: py,
nextFocusRef: {
current:
composerInternalApiRef.current?.input?.element,
},
})
},
)
}}
style={[
a.overflow_hidden,
a.absolute,
a.rounded_full,
a.align_center,
a.justify_center,
a.z_30,
{
height: 30,
width: 30,
top: 7,
left: 7,
},
]}
accessibilityLabel={l`Open emoji picker`}
accessibilityHint="">
{state => (
<View
style={[
a.absolute,
a.inset_0,
a.align_center,
a.justify_center,
{
backgroundColor:
state.hovered || state.focused || state.pressed
? t.atoms.bg.backgroundColor
: undefined,
},
]}>
<EmojiSmile size="lg" />
</View>
)}
</Pressable>
)}
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={l`Send message`} accessibilityLabel={l`Send message`}
accessibilityHint="" accessibilityHint=""
hitSlop={HITSLOP_10} hitSlop={HITSLOP_10}
style={[ style={[
a.absolute,
a.rounded_full, a.rounded_full,
a.align_center, a.align_center,
a.justify_center, a.justify_center,
a.self_end,
a.z_30, a.z_30,
{ {
height: 35, height: 44,
width: 35, width: 44,
backgroundColor: t.palette.primary_500, backgroundColor: t.palette.primary_500,
top: 4,
right: 4,
}, },
]} ]}
onPress={onSubmit} onPress={onSubmit}
@@ -234,7 +242,7 @@ export function MessageComposer({
style={[a.relative, {left: 1}]} style={[a.relative, {left: 1}]}
/> />
</Pressable> </Pressable>
</Composer> ) : null}
</View> </View>
</View> </View>
+8 -8
View File
@@ -2413,15 +2413,15 @@
resolved "https://registry.yarnpkg.com/@bsky.app/react-native-mmkv/-/react-native-mmkv-2.12.5.tgz#eb17d31a6158c74393f617a1763ac223ff3f83a6" resolved "https://registry.yarnpkg.com/@bsky.app/react-native-mmkv/-/react-native-mmkv-2.12.5.tgz#eb17d31a6158c74393f617a1763ac223ff3f83a6"
integrity sha512-3vUz1nQY1DiKIPAWRkpp5ZGxH5f2G6Ui0UuQuEYjYv81xx1qFcSzS9KQ2sHcOKYdkOM9amWV2Q8TQCxt1lrAHg== integrity sha512-3vUz1nQY1DiKIPAWRkpp5ZGxH5f2G6Ui0UuQuEYjYv81xx1qFcSzS9KQ2sHcOKYdkOM9amWV2Q8TQCxt1lrAHg==
"@bsky.app/sift@^0.2.6": "@bsky.app/sift@^0.2.8":
version "0.2.6" version "0.2.8"
resolved "https://registry.yarnpkg.com/@bsky.app/sift/-/sift-0.2.6.tgz#9ceeebd67e5cda693fbac666c424b2025b01d366" resolved "https://registry.yarnpkg.com/@bsky.app/sift/-/sift-0.2.8.tgz#58cb8ed82e6402061a1e2315a03e1aaec66e6718"
integrity sha512-AC6YXP24tusGtHXYT5rEcRVJkW5zAe9GksR4agi5XoCqcZ+Z2jf8QTPbU6wFQxybjmKUxW36WLF7uMMGo4+PaQ== integrity sha512-CVBo0fp8kpSPhiGYO1H0J8aaEREQ87fzoXdL2qaH64GgX+tqFJT9BlS4EvEyhJoyMHdRWinGgTTubf4Gkvcg/Q==
"@bsky.app/tapper@^0.4.1": "@bsky.app/tapper@^0.4.2":
version "0.4.1" version "0.4.2"
resolved "https://registry.yarnpkg.com/@bsky.app/tapper/-/tapper-0.4.1.tgz#f8f68f411fbd5ce421cf482a03078b18ac23ecac" resolved "https://registry.yarnpkg.com/@bsky.app/tapper/-/tapper-0.4.2.tgz#a01355f87bb17c4767cb66930a9d1e65904b298a"
integrity sha512-1ET0T34VKkKmaKj6WY6Gnmx0UC7FByPwGZ2SiLJUwJP6Ott5gBfk9xhaag3pdf3OZsZ3O1uIs99J7pdGcQVrew== integrity sha512-64+EI5zzIFAMnLFlHFRuS0L26ANCLnMQS1Q4GszOo6/vhWko6opQA0yYowizEFHCQ73uiquaup2Oc8m7pDSxTA==
"@crowdin/cli@^4.14.1": "@crowdin/cli@^4.14.1":
version "4.14.1" version "4.14.1"