Fix Emoji picker focus (#7217)
* Only portal the emoji picker where needed * Add optional portal prop to emoji picker * Use FocusScope to our advantage * Pare back, add guards, fix focus trap * Don't return focus to emoji button * Set DM input position on emoji insert * Let the caller determine next focus node --------- Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import {Pressable, StyleSheet, View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import Graphemer from 'graphemer'
|
||||
import {flushSync} from 'react-dom'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
|
||||
import {isSafari, isTouchDevice} from '#/lib/browser'
|
||||
@@ -106,11 +107,19 @@ export function MessageInput({
|
||||
|
||||
const onEmojiInserted = React.useCallback(
|
||||
(emoji: Emoji) => {
|
||||
const position = textAreaRef.current?.selectionStart ?? 0
|
||||
setMessage(
|
||||
message =>
|
||||
message.slice(0, position) + emoji.native + message.slice(position),
|
||||
)
|
||||
if (!textAreaRef.current) {
|
||||
return
|
||||
}
|
||||
const position = textAreaRef.current.selectionStart ?? 0
|
||||
textAreaRef.current.focus()
|
||||
flushSync(() => {
|
||||
setMessage(
|
||||
message =>
|
||||
message.slice(0, position) + emoji.native + message.slice(position),
|
||||
)
|
||||
})
|
||||
textAreaRef.current.selectionStart = position + emoji.native.length
|
||||
textAreaRef.current.selectionEnd = position + emoji.native.length
|
||||
},
|
||||
[setMessage],
|
||||
)
|
||||
@@ -148,7 +157,14 @@ export function MessageInput({
|
||||
<Button
|
||||
onPress={e => {
|
||||
e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => {
|
||||
openEmojiPicker?.({top: py, left: px, right: px, bottom: py})
|
||||
openEmojiPicker?.({
|
||||
top: py,
|
||||
left: px,
|
||||
right: px,
|
||||
bottom: py,
|
||||
nextFocusRef:
|
||||
textAreaRef as unknown as React.MutableRefObject<HTMLElement>,
|
||||
})
|
||||
})
|
||||
}}
|
||||
style={[
|
||||
|
||||
@@ -101,7 +101,7 @@ export function MessagesList({
|
||||
const [emojiPickerState, setEmojiPickerState] =
|
||||
React.useState<EmojiPickerState>({
|
||||
isOpen: false,
|
||||
pos: {top: 0, left: 0, right: 0, bottom: 0},
|
||||
pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null},
|
||||
})
|
||||
|
||||
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
|
||||
|
||||
@@ -13,6 +13,7 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {postUriToRelativePath, toBskyAppUrl} from '#/lib/strings/url-helpers'
|
||||
import {purgeTemporaryImageFiles} from '#/state/gallery'
|
||||
import {precacheResolveLinkQuery} from '#/state/queries/resolve-link'
|
||||
import type {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
|
||||
export interface ComposerOptsPostRef {
|
||||
@@ -29,7 +30,7 @@ export interface ComposerOpts {
|
||||
onPost?: (postUri: string | undefined) => void
|
||||
quote?: AppBskyFeedDefs.PostView
|
||||
mention?: string // handle of user to mention
|
||||
openEmojiPicker?: (pos: DOMRect | undefined) => void
|
||||
openEmojiPicker?: (pos: EmojiPickerPosition | undefined) => void
|
||||
text?: string
|
||||
imageUris?: {uri: string; width: number; height: number; altText?: string}[]
|
||||
videoUri?: {uri: string; width: number; height: number}
|
||||
|
||||
@@ -530,7 +530,14 @@ export const ComposePost = ({
|
||||
}
|
||||
|
||||
const onEmojiButtonPress = useCallback(() => {
|
||||
openEmojiPicker?.(textInput.current?.getCursorPosition())
|
||||
const rect = textInput.current?.getCursorPosition()
|
||||
if (rect) {
|
||||
openEmojiPicker?.({
|
||||
...rect,
|
||||
nextFocusRef:
|
||||
textInput as unknown as React.MutableRefObject<HTMLElement>,
|
||||
})
|
||||
}
|
||||
}, [openEmojiPicker])
|
||||
|
||||
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
GestureResponderEvent,
|
||||
TouchableWithoutFeedback,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {Pressable, useWindowDimensions, View} from 'react-native'
|
||||
import Picker from '@emoji-mart/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {DismissableLayer} from '@radix-ui/react-dismissable-layer'
|
||||
import {FocusScope} from '@radix-ui/react-focus-scope'
|
||||
|
||||
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {atoms as a, flatten} from '#/alf'
|
||||
import {Portal} from '#/components/Portal'
|
||||
|
||||
const HEIGHT_OFFSET = 40
|
||||
@@ -33,6 +31,7 @@ export interface EmojiPickerPosition {
|
||||
left: number
|
||||
right: number
|
||||
bottom: number
|
||||
nextFocusRef: React.MutableRefObject<HTMLElement> | null
|
||||
}
|
||||
|
||||
export interface EmojiPickerState {
|
||||
@@ -51,6 +50,7 @@ interface IProps {
|
||||
}
|
||||
|
||||
export function EmojiPicker({state, close, pinToTop}: IProps) {
|
||||
const {_} = useLingui()
|
||||
const {height, width} = useWindowDimensions()
|
||||
|
||||
const isShiftDown = React.useRef(false)
|
||||
@@ -119,48 +119,63 @@ export function EmojiPicker({state, close, pinToTop}: IProps) {
|
||||
|
||||
if (!state.isOpen) return null
|
||||
|
||||
const onPressBackdrop = (e: GestureResponderEvent) => {
|
||||
// @ts-ignore web only
|
||||
if (e.nativeEvent?.pointerId === -1) return
|
||||
close()
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<TouchableWithoutFeedback
|
||||
accessibilityRole="button"
|
||||
onPress={onPressBackdrop}
|
||||
accessibilityViewIsModal>
|
||||
<FocusScope
|
||||
loop
|
||||
trapped
|
||||
onUnmountAutoFocus={e => {
|
||||
const nextFocusRef = state.pos.nextFocusRef
|
||||
const node = nextFocusRef?.current
|
||||
if (node) {
|
||||
e.preventDefault()
|
||||
node.focus()
|
||||
}
|
||||
}}>
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityLabel={_(msg`Close emoji picker`)}
|
||||
accessibilityHint={_(msg`Tap to close the emoji picker`)}
|
||||
onPress={close}
|
||||
style={[a.fixed, a.inset_0]}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[
|
||||
style={flatten([
|
||||
a.fixed,
|
||||
a.w_full,
|
||||
a.h_full,
|
||||
a.align_center,
|
||||
a.z_10,
|
||||
{
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
]}>
|
||||
{/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */}
|
||||
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
|
||||
<View style={[{position: 'absolute'}, position]}>
|
||||
<DismissableLayer
|
||||
onFocusOutside={evt => evt.preventDefault()}
|
||||
onDismiss={close}>
|
||||
<Picker
|
||||
data={async () => {
|
||||
return (await import('./EmojiPickerData.json')).default
|
||||
}}
|
||||
onEmojiSelect={onInsert}
|
||||
autoFocus={true}
|
||||
/>
|
||||
</DismissableLayer>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
])}>
|
||||
<View style={[{position: 'absolute'}, position]}>
|
||||
<DismissableLayer
|
||||
onFocusOutside={evt => evt.preventDefault()}
|
||||
onDismiss={close}>
|
||||
<Picker
|
||||
data={async () => {
|
||||
return (await import('./EmojiPickerData.json')).default
|
||||
}}
|
||||
onEmojiSelect={onInsert}
|
||||
autoFocus={true}
|
||||
/>
|
||||
</DismissableLayer>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityLabel={_(msg`Close emoji picker`)}
|
||||
accessibilityHint={_(msg`Tap to close the emoji picker`)}
|
||||
onPress={close}
|
||||
style={[a.fixed, a.inset_0]}
|
||||
/>
|
||||
</FocusScope>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {useModals} from '#/state/modals'
|
||||
import {ComposerOpts, useComposerState} from '#/state/shell/composer'
|
||||
import {
|
||||
EmojiPicker,
|
||||
EmojiPickerPosition,
|
||||
EmojiPickerState,
|
||||
} from '#/view/com/composer/text-input/web/EmojiPicker.web'
|
||||
import {useBreakpoints, useTheme} from '#/alf'
|
||||
@@ -42,16 +43,19 @@ function Inner({state}: {state: ComposerOpts}) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const [pickerState, setPickerState] = React.useState<EmojiPickerState>({
|
||||
isOpen: false,
|
||||
pos: {top: 0, left: 0, right: 0, bottom: 0},
|
||||
pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null},
|
||||
})
|
||||
|
||||
const onOpenPicker = React.useCallback((pos: DOMRect | undefined) => {
|
||||
if (!pos) return
|
||||
setPickerState({
|
||||
isOpen: true,
|
||||
pos,
|
||||
})
|
||||
}, [])
|
||||
const onOpenPicker = React.useCallback(
|
||||
(pos: EmojiPickerPosition | undefined) => {
|
||||
if (!pos) return
|
||||
setPickerState({
|
||||
isOpen: true,
|
||||
pos,
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const onClosePicker = React.useCallback(() => {
|
||||
setPickerState(prev => ({
|
||||
|
||||
Reference in New Issue
Block a user